Merge branch 'master' of github.com:Lissy93/dashy into ARCH/docker-buildx

This commit is contained in:
Alicia Sykes 2022-01-23 12:20:13 +00:00
commit 24793c8108
135 changed files with 15825 additions and 3874 deletions

24
.github/AUTHORS.txt vendored
View File

@ -1,36 +1,42 @@
Alicia <liss-bot@d0h.co> - 1 commits
BOZG <sr@bozg.se> - 1 commits
Begin <support@begin.com> - 1 commits
David <skaarj1989@gmail.com> - 1 commits
DeepSource <o> - 1 commits
Devin <uh> - 1 commits
FormatToday <616099456@qq.com> - 1 commits
Iaroslav <ronski> - 1 commits
Ishan <ai> - 1 commits
Kieren <onnel> - 1 commits
Rune <jørnerå> - 1 commits
Ryan <urne> - 1 commits
Shreya <o> - 1 commits
Xert <xertdev@gmail.com> - 1 commits
deepsource-io[bot] <deepsource-io[bot]@users.noreply.github.com> - 1 commits
jnach <33467747+jnach@users.noreply.github.com> - 1 commits
Alicia <yke> - 2 commits
BOZG <sr@bozg.se> - 2 commits
Brendan <&#39;Lear> - 2 commits
Dan <ilber> - 2 commits
liss-bot <87835202+liss-bot@users.noreply.github.com> - 2 commits
ᗪєνιη <υн> - 2 commits
Walkx <71191962+walkxcode@users.noreply.github.com> - 3 commits
Niklas <abe> - 4 commits
Alicie <gh@d0h.co> - 5 commits
UrekD <urek.denis@gmail.com> - 5 commits
Erik <roo> - 6 commits
Leonardo <ovarrubia> - 6 commits
liss-bot <liss-bot@users.noreply.github.com> - 6 commits
Kashif <ohai> - 9 commits
Alicia <yke> - 16 commits
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> - 16 commits
repo-visualizer <repo-visualizer@users.noreply.github.com> - 16 commits
snyk-bot <snyk-bot@users.noreply.github.com> - 18 commits
snyk-bot <snyk-bot@snyk.io> - 20 commits
repo-visualizer <repo-visualizer@users.noreply.github.com> - 21 commits
EVOTk <45015615+EVOTk@users.noreply.github.com> - 22 commits
snyk-bot <snyk-bot@snyk.io> - 25 commits
Alicia <yke> - 28 commits
Alicia <o> - 33 commits
liss-bot <liss-bot@d0h.co> - 44 commits
Alicia <o> - 41 commits
liss-bot <liss-bot@d0h.co> - 56 commits
Alicia <yke> - 60 commits
Lissy93 <gh@d0h.co> - 78 commits
Lissy93 <Lissy93@users.noreply.github.com> - 202 commits
Alicia <yke> - 302 commits
Alicia <yke> - 1230 commits
Lissy93 <Lissy93@users.noreply.github.com> - 203 commits
Alicia <yke> - 319 commits
Alicia <yke> - 1310 commits

32
.github/CHANGELOG.md vendored
View File

@ -1,5 +1,37 @@
# Changelog
## 💄 1.9.9 - Minor UI + Docs Updates [PR #431](https://github.com/Lissy93/dashy/pull/431)
- Improved theme support for widgets
- Better widget layout in Workspace and Minimal views
- Updates lots of the docs
## ✨ 1.9.8 - More Widgets and Widget Improvements [PR #425](https://github.com/Lissy93/dashy/pull/425)
- Fixes several minor widget issues raised by users
- Adds several new widgets, for monitoring system
- Better widget data requests and error handling
- Implements widget support into Workspace view
## 🐛 1.9.7 - Minor UI Editor Bug fixes [PR #416](https://github.com/Lissy93/dashy/pull/416)
- Fixes unable to edit item bug (#415)
- Fixes unable to add new app bug (#390)
- Fixes nav links visibility (#389)
## ⚡️ 1.9.6 - Adds Proxy Support for Widget Requests [PR #392](https://github.com/Lissy93/dashy/pull/392)
- Refactors widget mixin to include data requests, so that code can be shared between widgets
- Adds a Node endpoint for proxying requests server-side, used for APIs that are not CORS enabled
- Adds option to config file for user to force proxying of requests
- Writes a Netlify cloud function to support proxying when the app is hosted on Netlify
## 🐛 1.9.5 - Bug fixes and Minor Improvements [PR #388](https://github.com/Lissy93/dashy/pull/388)
- Adds icon.horse to supported favicon APIs
- Fixes tile move bug, Re: #366
- Fixes save items without title bug, Re: #377
## ✨ 1.9.4 - Widget Support [PR #382](https://github.com/Lissy93/dashy/pull/382)
- Adds support for dynamic content, through widgets
- Adds 30+ pre-built widgets for general info and self-hosted services
- Writes docs on widget usage
## ⚡️ 1.9.2 - Native SSL Support + Performance Improvements [PR #326](https://github.com/Lissy93/dashy/pull/326)
- Updates the server to use Express, removing serve-static, connect and body-parser
- Adds native support for passing in self-signed SSL certificates and updates docs

View File

@ -1,62 +1,64 @@
name: Question 🤷‍♂️
description: Got a question about Dashy, deployment, development or usage?
title: '[QUESTION] <title>'
labels: ['🤷‍♂️ Question']
body:
# Filed 1 - Intro Text
- type: markdown
attributes:
value: >
Thanks for using Dashy! Questions are welcome, but in the future will be moving over to
[Discussions](https://github.com/Lissy93/dashy/discussions) page.
Quick questions should be asked [here](https://github.com/Lissy93/dashy/discussions/148) instead.
validations:
required: false
# Field 2 - The actual question
- type: textarea
id: question
attributes:
label: Question
description: Outline your question in a clear and concise manner
validations:
required: true
# Field 3 - Category
- type: dropdown
id: category
attributes:
label: Category
description: What part of the application does this relate to?
options:
- Setup and Deployment
- Configuration
- App Usage
- Development
- Documentation
- Alternate Views
- Authentication
- Using Icons
- Language Support
- Search & Shortcuts
- Status Checking
- Theming & Layout
validations:
required: true
# Field 4 - User has RTFM first, and agrees to code of conduct, etc
- type: checkboxes
id: idiot-check
attributes:
label: Please tick the boxes
description: Before submitting, please ensure that
options:
- label: You are using a [supported](https://github.com/Lissy93/dashy/blob/master/.github/SECURITY.md#supported-versions) version of Dashy (check the first two digits of the version number)
required: true
- label: You've checked that this [question hasn't already been raised](https://github.com/Lissy93/dashy/issues?q=is%3Aissue)
required: true
- label: You've checked the [docs](https://github.com/Lissy93/dashy/tree/master/docs#readme) and [troubleshooting](https://github.com/Lissy93/dashy/blob/master/docs/troubleshooting.md#troubleshooting) guide
required: true
- label: You agree to the [code of conduct](https://github.com/Lissy93/dashy/blob/master/.github/CODE_OF_CONDUCT.md#contributor-covenant-code-of-conduct)
required: true
name: Question 🤷‍♂️
description: Got a question about Dashy, deployment, development or usage?
title: '[QUESTION] <title>'
labels: ['🤷‍♂️ Question']
body:
# Filed 1 - Intro Text
- type: markdown
attributes:
value: >
Thanks for using Dashy! Questions are welcome, but in the future will be moving over to
[Discussions](https://github.com/Lissy93/dashy/discussions) page.
Quick questions should be asked [here](https://github.com/Lissy93/dashy/discussions/148) instead.
validations:
required: false
# Field 2 - The actual question
- type: textarea
id: question
attributes:
label: Question
description: Outline your question in a clear and concise manner
validations:
required: true
# Field 3 - Category
- type: dropdown
id: category
attributes:
label: Category
description: What part of the application does this relate to?
options:
- Setup and Deployment
- Configuration
- App Usage
- Development
- Documentation
- Alternate Views
- Authentication
- Using Icons
- Widgets
- Actions
- Language Support
- Search & Shortcuts
- Status Checking
- Theming & Layout
validations:
required: true
# Field 4 - User has RTFM first, and agrees to code of conduct, etc
- type: checkboxes
id: idiot-check
attributes:
label: Please tick the boxes
description: Before submitting, please ensure that
options:
- label: You are using a [supported](https://github.com/Lissy93/dashy/blob/master/.github/SECURITY.md#supported-versions) version of Dashy (check the first two digits of the version number)
required: true
- label: You've checked that this [question hasn't already been raised](https://github.com/Lissy93/dashy/issues?q=is%3Aissue)
required: true
- label: You've checked the [docs](https://github.com/Lissy93/dashy/tree/master/docs#readme) and [troubleshooting](https://github.com/Lissy93/dashy/blob/master/docs/troubleshooting.md#troubleshooting) guide
required: true
- label: You agree to the [code of conduct](https://github.com/Lissy93/dashy/blob/master/.github/CODE_OF_CONDUCT.md#contributor-covenant-code-of-conduct)
required: true

543
.github/LEGAL.md vendored
View File

@ -1,157 +1,18 @@
# 3rd-Party Software for Dashy
---
The following 3rd-party software packages may be used by or distributed with **dashy**. Any information relevant to third-party vendors listed below are collected using common, reasonable means.
Date generated | Revision ID
:------------: | :----------:
10/02/21 | cc7101a871e151a2dea7830d1e492ef6ef05fd53
01/22/22 | cc7101a871e151a2dea7830d1e492ef6ef05fd53
---
## Dependencies
### [@sentry/tracing (6.11.0)](https://www.npmjs.com/package/@sentry/tracing)
#### Declared Licenses
MIT
```
MIT License
Copyright (c) 2020 Sentry (https://sentry.io/) and individual contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
#### Other Licenses
Apache-2.0
```
Copyright 2020 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
```
---
### [@sentry/vue (6.11.0)](https://www.npmjs.com/package/@sentry/vue)
#### Declared Licenses
**Multi-license:**MIT, **Multi-license:**BSD-3-Clause
```
Copyright (c) 2021, @sentry/vue Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
```
MIT License
Copyright (c) 2019, Sentry
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
\* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
\* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
\* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
#### Other Licenses
Apache-2.0
```
Copyright Microsoft Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
```
---
### [ajv (8.6.2)](https://www.npmjs.com/package/ajv)
#### Declared Licenses
MIT
```
The MIT License (MIT)
@ -178,16 +39,15 @@ SOFTWARE.
```
#### Other Licenses
---
### [axios (0.21.1)](https://www.npmjs.com/package/axios)
#### Declared Licenses
MIT
```
Copyright (c) 2014-present Matt Zabriskie
@ -211,15 +71,15 @@ THE SOFTWARE.
```
#### Other Licenses
---
### [body-parser (1.19.0)](https://www.npmjs.com/package/body-parser)
#### Declared Licenses
MIT
```
(The MIT License)
@ -246,17 +106,15 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
#### Other Licenses
---
### [connect (3.7.0)](https://www.npmjs.com/package/connect)
#### Declared Licenses
MIT
```
(The MIT License)
@ -286,16 +144,15 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
#### Other Licenses
---
### [crypto-js (4.1.1)](https://www.npmjs.com/package/crypto-js)
#### Declared Licenses
MIT
```
\# License
@ -324,13 +181,12 @@ THE SOFTWARE.
```
#### Other Licenses
BSD-2-Clause
```
Copyright (c) 2021, crypto-js Contributors<<beginOptional>>
Copyright (c) 2022, crypto-js Contributors<<beginOptional>>
All rights reserved.<<endOptional>>
Redistribution and use in source and binary forms, with or without
@ -390,6 +246,7 @@ THE SOFTWARE.
```
#### Other Licenses
---
@ -402,7 +259,7 @@ Apache-2.0
```
Copyright 2016 Red Hat, Inc
Copyright 2016 Red Hat, Inc. and/or its affiliates
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -417,13 +274,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
```
#### Other Licenses
MIT
**Multi-license:**MIT, **Multi-license:**Apache-2.0 *OR* MIT
```
Copyright (c) 2017 Brett Epps
Copyright (c) 2017 Brett Epps <https://github.com/eppsilon>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
@ -444,7 +300,6 @@ SOFTWARE.
```
---
### [register-service-worker (1.7.2)](https://www.npmjs.com/package/register-service-worker)
@ -479,6 +334,8 @@ THE SOFTWARE.
```
#### Other Licenses
---
@ -489,6 +346,55 @@ THE SOFTWARE.
Apache-2.0 *OR* MIT
```
Copyright 2018 AJ ONeal
This is open source software; you can redistribute it and/or modify it under the
terms of either:
a) the "MIT License"
b) the "Apache-2.0 License"
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Apache-2.0 License Summary
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
#### Other Licenses
MIT
```
Copyright 2018 AJ ONeal
@ -572,6 +478,150 @@ OTHER DEALINGS IN THE SOFTWARE.
```
#### Other Licenses
---
### [@sentry/tracing (6.11.0)](https://www.npmjs.com/package/@sentry/tracing)
#### Declared Licenses
MIT
```
MIT License
Copyright (c) 2020 Sentry (https://sentry.io/) and individual contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
#### Other Licenses
Apache-2.0
```
Copyright Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
```
---
### [@sentry/vue (6.11.0)](https://www.npmjs.com/package/@sentry/vue)
#### Declared Licenses
BSD-3-Clause *OR* MIT
```
MIT License
Copyright (c) 2019, Sentry
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
\* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
\* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
\* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
#### Other Licenses
**Multi-license:**Apache-2.0, **Multi-license:**MIT
```
Copyright Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
```
```
Copyright (c) 2019 Sentry
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
---
@ -611,6 +661,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
#### Other Licenses
---
@ -656,126 +707,7 @@ For more information, please see http://creativecommons.org/publicdomain/zero/1.
```
#### Other Licenses
**Multi-license:**Apache-2.0, **Multi-license:**MIT, **Multi-license:**GPL-2.0-or-later, **Multi-license:**GPL-3.0-only, **Multi-license:**W3C, **Multi-license:**GPL-3.0-or-later *OR* W3C, **Multi-license:**GPL-3.0-or-later, **Multi-license:**BSD-3-Clause, **Multi-license:**PHP-3.01
```
Copyright 2021, simple-icons Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
```
```
Copyright (c) 2021, simple-icons Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
```
Copyright (C) 2021, simple-icons Contributors
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; version 2 or any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
```
```
Copyright (C) 2021, simple-icons Contributors
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/
```
```
Copyright (C) [$date-of-software] World Wide Web Consortium, (Massachusetts Institute of Technology, European Research Consortium for Informatics and Mathematics, Keio University). All Rights Reserved.
This work is distributed under the W3C® Software License in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
```
```
Copyright (C) 2021, simple-icons Contributors
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 or any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/
```
```
Copyright (c) 2021, simple-icons Contributors . All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
```
Copyright (c) 1999 - 2012 The PHP Group. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, is permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. The name "PHP" must not be used to endorse or promote products derived from this software without prior written permission. For written permission, please contact group@php.net.
4. Products derived from this software may not be called "PHP", nor may "PHP" appear in their name, without prior written permission from group@php.net. You may indicate that your software works in conjunction with PHP by saying "Foo for PHP" instead of calling it "PHP Foo" or "phpfoo"
5. The PHP Group may publish revised and/or new versions of the license from time to time. Each version will be given a distinguishing version number. Once covered code has been published under a particular version of the license, you may always continue to use it under the terms of that version. You may also choose to use such covered code under the terms of any subsequent version of the license published by the PHP Group. No one other than the PHP Group has the right to modify the terms applicable to covered code created under this License.
6. Redistributions of any form whatsoever must retain the following acknowledgment: "This product includes PHP software, freely available from <http://www.php.net/software/>".
THIS SOFTWARE IS PROVIDED BY THE PHP DEVELOPMENT TEAM ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE PHP DEVELOPMENT TEAM OR ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
This software consists of voluntary contributions made by many individuals on behalf of the PHP Group.
The PHP Group can be contacted via Email at group@php.net.
For more information on the PHP Group and the PHP project, please see <http://www.php.net>.
PHP includes the Zend Engine, freely available at <http://www.zend.com>.
```
---
@ -811,6 +743,25 @@ SOFTWARE.
```
#### Other Licenses
Apache-2.0
```
Copyright 2011-2021 Jos de Jong, http://jsoneditoronline.org
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
```
---
@ -846,7 +797,7 @@ SOFTWARE.
```
#### Other Licenses
---
@ -882,13 +833,12 @@ THE SOFTWARE.
```
#### Other Licenses
**Multi-license:**Apache-2.0, **Multi-license:**GPL-2.0-or-later
**Multi-license:**Apache-2.0 *OR* GPL-2.0-or-later *OR* MPL-1.1, **Multi-license:**Apache-2.0
```
Copyright 2021, vue Contributors
Copyright 2022, vue Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -904,14 +854,6 @@ See the License for the specific language governing permissions and limitations
```
```
Copyright (C) 2021, vue Contributors
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; version 2 or any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
```
---
### [vue-i18n (8.25.0)](https://www.npmjs.com/package/vue-i18n)
@ -945,6 +887,9 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
#### Other Licenses
---
@ -980,6 +925,7 @@ SOFTWARE.
```
#### Other Licenses
---
@ -1015,6 +961,8 @@ SOFTWARE.
```
#### Other Licenses
---
@ -1050,6 +998,8 @@ SOFTWARE.
```
#### Other Licenses
---
@ -1084,8 +1034,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
#### Other Licenses
---
@ -1121,6 +1070,8 @@ SOFTWARE.
```
#### Other Licenses
---
### [vue-toasted (1.1.28)](https://www.npmjs.com/package/vue-toasted)
@ -1153,4 +1104,14 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
#### Other Licenses
---
[FOSSA]: # (Do not touch the comments below)
[FOSSA]: # (==depsig=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855==)

View File

@ -40,6 +40,7 @@
- [🎨 Theming](#theming-)
- [🧸 Icons](#icons-)
- [🚦 Status Indicators](#status-indicators-)
- [📊 Widgets](#widgets-)
- [💂 Authentication](#authentication-)
- [🖱️ Opening Methods](#opening-methods-%EF%B8%8F)
- [👓 Alternate Views](#alternate-views-)
@ -70,6 +71,7 @@
- 🎨 Multiple built-in color themes, with UI color editor and support for custom CSS
- 🧸 Many icon options - Font-Awesome, homelab icons, auto-fetching Favicon, images, emojis, etc.
- 🚦 Status monitoring for each of your apps/links for basic availability and uptime checking
- 📊 Use widgets to display info and dynamic content from self-hosted services
- 💂 Optional authentication with multi-user access, configurable privileges, and SSO support
- 🌎 Multi-language support, with 10+ human-translated languages, and more on the way
- ☁ Optional, encrypted, free off-site cloud backup and restore feature available
@ -235,6 +237,22 @@ Status indicators can be globally enabled by setting `appConfig.statusCheck: tru
<img alt="Status Checks demo" src="https://raw.githubusercontent.com/Lissy93/dashy/master/docs/assets/status-check-demo.gif" width="600" />
</p>
**[⬆️ Back to Top](#dashy)**
---
## Widgets 📊
> For full widget documentation, see: [**Widgets**](./docs/widgets.md)
You can display dynamic content from services in the form of widgets. There are several pre-built widgets availible for showing useful info, and integrations with commonly self-hosted services, but you can also easily create your own for almost any app.
<p align="center">
<img width="600" src="https://i.ibb.co/GFjXVHy/dashy-widgets.png" />
</p>
**[⬆️ Back to Top](#dashy)**
---
@ -310,7 +328,7 @@ For apps that you use regularly, you can set a custom keybinding. Use the `hotke
You can also add custom tags to a given item to make finding them based on keywords easier. For example, in the following example, searching for 'Movies' will show 'Plex'
"`yaml
```yaml
items:
- title: Plex
hotkey: 8
@ -472,7 +490,7 @@ Huge thanks to the sponsors helping to support Dashy's development!
</td>
<td align="center">
<a href="https://github.com/Robert-Ernst">
<img src="https://avatars.githubusercontent.com/u/9050259?v=4" width="80;" alt="Robert-Ernst"/>
<img src="https://avatars.githubusercontent.com/u/9050259?u=7253b4063f1ffe3b5a894263c8b2056151802508&v=4" width="80;" alt="Robert-Ernst"/>
<br />
<sub><b>Robert Ernst</b></sub>
</a>
@ -485,10 +503,17 @@ Huge thanks to the sponsors helping to support Dashy's development!
</a>
</td>
<td align="center">
<a href="https://github.com/matthewjdegarmo">
<img src="https://avatars.githubusercontent.com/u/46715299?u=65f979e86de9dce8a5fe04df9775d32c6cefd73a&v=4" width="80;" alt="matthewjdegarmo"/>
<a href="https://github.com/KierenConnell">
<img src="https://avatars.githubusercontent.com/u/46445781?u=5502f8fb780938e2825735d7bbb9236642d212c0&v=4" width="80;" alt="KierenConnell"/>
<br />
<sub><b>Matthew J. DeGarmo</b></sub>
<sub><b>Kieren Connell</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ratty222">
<img src="https://avatars.githubusercontent.com/u/92832598?v=4" width="80;" alt="ratty222"/>
<br />
<sub><b>Ratty222</b></sub>
</a>
</td></tr>
</table>

View File

@ -1,10 +1,16 @@
# Alternate Views & Opening Methods
## Views
As well as the default start view, Dashy has several other start pages, for different tasks. You can switch views with the view-switcher button in the top-right, or set a default starting view using the `appConfig.startingView` attribute (can be `default`, `minimal` or `workspace`).
Dashy has three different views:
- Default View - This is the main homepage with sections in a grid layout
- Workspace View - Items displayed on the side, and are launched within Dashy
- Minimal View - A clean + simple tabbed view
You can switch between views using the dropdown in the top-right corner. Set your chosen Starting View with `appConfig.startingView`. Click the page title at any time to go back to your selected starting view.
### Default
This is the main page that you will land on when you first launch the application. Here all of your sections and items will be visible, you can modify settings and search + launch your applications.
This is the main page that you will land on when you first launch the application. Here all of your sections (with items + widgets) are visible in a grid layout.
<p align="center">
<b>Example of Default View</b><br>
@ -12,11 +18,11 @@ This is the main page that you will land on when you first launch the applicatio
</p>
### Workspace
The workspace view displays your links in a sidebar on the left-hand side, and apps are launched within Dashy. This enables you to use all of your self-hosted apps from one place, and makes multi-tasking easy.
The workspace view displays your links in a sidebar on the left-hand side, and apps are launched inside an iframe without having to leave Dashy. This enables you to use all of your self-hosted apps from one place, and makes multi-tasking easy.
In the workspace view, you can opt to keep previously opened websites/ apps open in the background, by setting `appConfig.enableMultiTasking: true`. This comes at the cost of performance, but does mean that your session with each app is preserved, enabling you to quickly switch between your apps.
You can specify a default app to be opened when you land on the workspace, by setting `appConfig.workspaceLandingUrl: https://app-to-open/`. If this app exists within your sections.items, then the corresponding section will also be expanded.
You can also specify a default app to be opened when you land on the workspace, by setting `appConfig.workspaceLandingUrl: https://app-to-open/`. If this app exists within your sections.items, then the corresponding section will also be expanded.
You can also opt to keep previously opened websites/ apps open in the background, by setting `appConfig.enableMultiTasking: true`. This comes at the cost of performance, but does mean that your session with each app is preserved, enabling you to quickly switch between them.
<p align="center">
<b>Example of Workspace View</b><br>
@ -24,7 +30,7 @@ You can also specify a default app to be opened when you land on the workspace,
</p>
### Minimal View
The minimal view aims to be super fast and simple, and can be used as a browser startpage. Items are grouped into a tab view, and the last opened tab will be remembered. Similar to the main view, you can search and launch items just by typing, and right-clicking will show more options.
The minimal view aims to be super fast and simple, and can be used as a browser startpage. Items are grouped into a tab view, and the last opened tab will be remembered. Similar to the main view, you can search and launch items just by typing, and right-clicking will show more options (like open in modal, workspace or new tab).
<p align="center">
<b>Example of Minimal View</b><br>
@ -33,7 +39,7 @@ The minimal view aims to be super fast and simple, and can be used as a browser
## Opening Methods
Dashy supports several different ways to launch your apps. The default opening method for each app can be specified using the `target` attribute, with a value of one of the following:
Dashy supports several different ways to launch your apps. The primary opening method for each app can be specified using the `target` attribute, with a value of one of the following:
- `sametab` - The app will be launched in the current tab
- `newtab` - The app will be launched in a new tab
@ -41,12 +47,14 @@ Dashy supports several different ways to launch your apps. The default opening m
- `modal` - Launch app in a resizable/ movable popup modal on the current page
- `workspace` - Changes to Workspace view, and launches app
You can also set the default opening method, which will be applied to all items that don't have a specified target, using `appConfig.defaultOpeningMethod`, to one of the above values.
You can also set a default opening method, which will be applied to all items that don't have a specified target, using `appConfig.defaultOpeningMethod`, to one of the above values.
Even if the target is not set (or is set to `sametab`), you can still launch any given app in an alternative method: Alt + Click will open the modal, and Ctrl + Click will open in a new tab. You can also right-click on any item to see all options (as seen in the screenshot below). This custom context menu can be disabled by setting `appConfig.disableContextMenu: true`.
Even if the target is not set (or is set to `sametab`), you can still launch any given app in an alternative method. Either right-click to see all options, or use one of the keyboard shortcuts: Alt + Click will open the modal, and Ctrl + Click will open in a new tab.
<p align="center">
<img width="500" src="https://i.ibb.co/vmZdSRt/dashy-context-menu-2.png" />
</p>
If you don't like the custom context menu, it can be disabled by setting `appConfig.disableContextMenu: true`.
If you get a 'Refused to Connect' error in the modal or workspace views, then the target app has it's X-Frame-Options HTTP set to block requests from embedded content. You can easily fix this by setting this header to ALLOW, for instructions on how to do so, see the [Troubleshooting Docs](/docs/troubleshooting.md#refused-to-connect-in-modal-or-workspace-view).

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 3.8 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

@ -143,9 +143,25 @@ appConfig:
realm: 'alicia-homelab'
clientId: 'dashy'
```
### 4. Add groups and roles (Optional)
Keycloak allows you to assign users roles and groups. You can use these values to configure who can access various sections in Dashy.
Keycloak server administration and configuration is a deep topic; please refer to the [server admin guide](https://www.keycloak.org/docs/latest/server_admin/index.html#assigning-permissions-and-access-using-roles-and-groups) to see details about creating and assigning roles and groups.
Once you have groups or roles assigned to users you can configure access under each sections `displayData.showForKeycloakUser` and `displayData.hideForKeycloakUser`.
Both show and hide configurations accept a list of `groups` and `roles` that limit access. If a users data matches one or more items in these lists they will be allowed or excluded as defined.
```yaml
sections:
- name: DeveloperResources
displayData:
showForKeycloakUsers:
roles: ['canViewDevResources']
hideForKeycloakUsers:
groups: ['ProductTeam']
```
Your app is now secured :) When you load Dashy, it will redirect to your Keycloak login page, and any user without valid credentials will be prevented from accessing your dashboard.
From within the Keycloak console, you can then configure things like user permissions, time outs, password policies, access, etc. You can also backup your full Keycloak config, and it is recommended to do this, along with your Dashy config. You can spin up both Dashy and Keycloak simultaneously and restore both applications configs using a `docker-compose.yml` file, and this is recommended.
From within the Keycloak console, you can then configure things like time-outs, password policies, etc. You can also backup your full Keycloak config, and it is recommended to do this, along with your Dashy config. You can spin up both Dashy and Keycloak simultaneously and restore both applications configs using a `docker-compose.yml` file, and this is recommended.
---

View File

@ -1,298 +1,323 @@
# Configuring
All app configuration is specified in [`/public/conf.yml`](https://github.com/Lissy93/dashy/blob/master/public/conf.yml) which is in [YAML Format](https://yaml.org/) format. Changes can also be made [directly through the UI](#editing-config-through-the-ui) and previewed live, from here you can also export, backup, reset, validate and download your configuration file.
The following file provides a reference of all supported configuration options.
---
#### Contents
- [**`pageInfo`**](#pageinfo) - Header text, footer, title, navigation, etc
- [`navLinks`](#pageinfonavlinks-optional) - Navigation bar items and links
- [**`appConfig`**](#appconfig-optional) - Main application settings
- [`webSearch`](#appconfigwebsearch-optional) - Configure web search engine options
- [`hideComponents`](#appconfighidecomponents-optional) - Show/ hide page components
- [`auth`](#appconfigauth-optional) - Built-in authentication setup
- [`users`](#appconfigauthusers-optional) - Setup for simple auth
- [`keycloak`](#appconfigauthkeycloak-optional) - Auth using Keycloak
- [**`sections`**](#section) - List of sections
- [`displayData`](#sectiondisplaydata-optional) - Section display settings
- [`icon`](#sectionicon-and-sectionitemicon) - Icon for a section
- [`items`](#sectionitem) - List of items
- [`icon`](#sectionicon-and-sectionitemicon) - Icon for an item
- [**Notes**](#notes)
- [Editing Config through the UI](#editing-config-through-the-ui)
- [About YAML](#about-yaml)
- [Config Saving Methods](#config-saving-methods)
- [Preventing Changes](#preventing-changes-being-written-to-disk)
- [Example](#example)
---
Tips:
- You may find it helpful to look at some sample config files to get you started, a collection of which can be found [here](https://gist.github.com/Lissy93/000f712a5ce98f212817d20bc16bab10)
- You can check that your config file fits the schema, by running `yarn validate-config`
- After modifying your config, the app needs to be recompiled, by running `yarn build` - this happens automatically whilst the app is running if you're using Docker
- It is recommended to make and keep a backup of your config file. You can download your current config through the UI either from the Config menu, or using the `/download` endpoint. Alternatively, you can use the [Cloud Backup](./docs/backup-restore.md) feature.
- The config can also be modified directly through the UI, validated and written to the conf.yml file.
- All fields are optional, unless otherwise stated.
---
### Top-Level Fields
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`pageInfo`** | `object` | Required | Basic meta data like title, description, nav bar links, footer text. See [`pageInfo`](#pageinfo)
**`appConfig`** | `object` | _Optional_ | Settings related to how the app functions, including API keys and global styles. See [`appConfig`](#appconfig-optional)
**`sections`** | `array` | Required | An array of sections, each containing an array of items, which will be displayed as links. See [`section`](#section)
**[⬆️ Back to Top](#configuring)**
### `PageInfo`
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`title`** | `string` | Required | Your dashboard title, displayed in the header and browser tab
**`description`** | `string` | _Optional_ | Description of your dashboard, also displayed as a subtitle
**`navLinks`** | `array` | _Optional_ | Optional list of a maximum of 6 links, which will be displayed in the navigation bar. See [`navLinks`](#pageinfonavlinks-optional)
**`footerText`** | `string` | _Optional_ | Text to display in the footer (note that this will override the default footer content). This can also include HTML and inline CSS
**`logo`** | `string` | _Optional_ | The path to an image to display in the header (to the right of the title). This can be either local, where `/` is the root of `./public`, or any remote image, such as `https://i.ibb.co/yhbt6CY/dashy.png`. It's recommended to scale your image down, so that it doesn't impact load times
**[⬆️ Back to Top](#configuring)**
### `pageInfo.navLinks` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`title`** | `string` | Required | The text to display on the link button
**`path`** | `string` | Required | The URL to navigate to when clicked. Can be relative (e.g. `/about`) or absolute (e.g. `https://example.com` or `http://192.168.1.1`)
**[⬆️ Back to Top](#configuring)**
### `appConfig` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`language`** | `string` | _Optional_ | The 2 (or 4-digit) [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language, e.g. `en` or `en-GB`. This must be a language that the app has already been [translated](https://github.com/Lissy93/dashy/tree/master/src/assets/locales) into. If your language is unavailable, Dashy will fallback to English. By default Dashy will attempt to auto-detect your language, although this may not work on some privacy browsers.
**`startingView`** | `enum` | _Optional_ | Which page to load by default, and on the base page or domain root. You can still switch to different views from within the UI. Can be either `default`, `minimal` or `workspace`. Defaults to `default`
**`defaultOpeningMethod`** | `enum` | _Optional_ | The default opening method for items, if no `target` is specified for a given item. Can be either `newtab`, `sametab`, `top`, `parent`, `modal` or `workspace`. Defaults to `newtab`
**`statusCheck`** | `boolean` | _Optional_ | When set to `true`, Dashy will ping each of your services and display their status as a dot next to each item. This can be overridden by setting `statusCheck` under each item. Defaults to `false`
**`statusCheckInterval`** | `boolean` | _Optional_ | The number of seconds between checks. If set to `0` then service will only be checked on initial page load, which is usually the desired functionality. If value is less than `10` you may experience a hit in performance. Defaults to `0`
**`webSearch`** | `object` | _Optional_ | Configuration options for the web search feature, set your default search engine, opening method or disable web search. See [`webSearch`](#appconfigwebsearch-optional)
**`backgroundImg`** | `string` | _Optional_ | Path to an optional full-screen app background image. This can be either remote (http) or local (/). Note that this will slow down initial load
**`enableFontAwesome`** | `boolean` | _Optional_ | Where `true` is enabled, if left blank font-awesome will be enabled only if required by 1 or more icons
**`fontAwesomeKey`** | `string` | _Optional_ | If you have a font-awesome key, then you can use it here and make use of premium icons. It is a 10-digit alpha-numeric string from you're FA kit URL (e.g. `13014ae648`)
**`faviconApi`** | `enum` | _Optional_ | Only applicable if you are using favicons for item icons. Specifies which service to use to resolve favicons. Set to `local` to do this locally, without using an API. Services running locally will use this option always. Available options are: `local`, `faviconkit`, `google`, `clearbit`, `webmasterapi` and `allesedv`. Defaults to `faviconkit`. See [Icons](/docs/icons.md#favicons) for more info
**`auth`** | `object` | _Optional_ | All settings relating to user authentication. See [`auth`](#appconfigauth-optional)
**`layout`** | `enum` | _Optional_ | Layout for homepage, either `horizontal`, `vertical` or `auto`. Defaults to `auto`. This specifies the layout and direction of how sections are positioned on the home screen. This can also be modified and overridden from the UI.
**`iconSize`** | `enum` | _Optional_ | The size of link items / icons. Can be either `small`, `medium,` or `large`. Defaults to `medium`. This can also be set directly from the UI.
**`colCount`** | `number` | _Optional_ | The number of columns of sections displayed on the homepage, using the default view. Should be in integer between `1` and `8`. Note that by default this is applied responsively, based on current screen size, and specifying a value here will override this behavior, which may not be desirable.
**`theme`** | `string` | _Optional_ | The default theme for first load (you can change this later from the UI)
**`cssThemes`** | `string[]` | _Optional_ | An array of custom theme names which can be used in the theme switcher dropdown
**`customColors`** | `object`| _Optional_ | Enables you to apply a custom color palette to any given theme. Use the theme name (lowercase) as the key, for an object including key-value-pairs, with the color variable name as keys, and 6-digit hex code as value. See [Theming](/docs/theming.md#modifying-theme-colors) for more info
**`externalStyleSheet`** | `string` or `string[]` | _Optional_ | Either a URL to an external stylesheet or an array or URLs, which can be applied as themes within the UI
**`customCss`** | `string` | _Optional_ | Raw CSS that will be applied to the page. This can also be set from the UI. Please minify it first.
**`hideComponents`** | `object` | _Optional_ | A list of key page components (header, footer, search, settings, etc) that are present by default, but can be removed using this option. See [`appConfig.hideComponents`](#appconfighideComponents-optional)
**`routingMode`** | `string` | _Optional_ | Can be either `hash` or `history`. Determines the URL format for sub-pages, hash mode will look like `/#/home` whereas with history mode available you have nice clean URLs, like `/home`. For more info, see the [Vue docs](https://router.vuejs.org/guide/essentials/history-mode.html#example-server-configurations). If you're hosting Dashy with a custom BASE_URL, you will find that a bit of extra server config is necessary to get history mode working, so here you may want to instead use `hash` mode.Defaults to `history`.
**`enableMultiTasking`** | `boolean` | _Optional_ | If set to true, will keep apps open in the background when in the workspace view. Useful for quickly switching between multiple sites, and preserving their state, but comes at the cost of performance.
**`workspaceLandingUrl`** | `string` | _Optional_ | The URL or an app, service or website to launch when the workspace view is opened, before another service has been launched
**`allowConfigEdit`** | `boolean` | _Optional_ | Should prevent / allow the user to write configuration changes to the conf.yml from the UI. When set to `false`, the user can only apply changes locally using the config editor within the app, whereas if set to `true` then changes can be written to disk directly through the UI. Defaults to `true`. Note that if authentication is enabled, the user must be of type `admin` in order to apply changes globally.
**`enableErrorReporting`** | `boolean` | _Optional_ | Enable reporting of unexpected errors and crashes. This is off by default, and **no data will ever be captured unless you explicitly enable it**. Turning on error reporting helps previously unknown bugs get discovered and fixed. Dashy uses [Sentry](https://github.com/getsentry/sentry) for error reporting. Defaults to `false`.
**`sentryDsn`** | `boolean` | _Optional_ | If you need to monitor errors in your instance, then you can use Sentry to collect and process bug reports. Sentry can be self-hosted, or used as SaaS, once your instance is setup, then all you need to do is pass in the DSN here, and enable error reporting. You can learn more on the [Sentry DSN Docs](https://docs.sentry.io/product/sentry-basics/dsn-explainer/). Note that this will only ever be used if `enableErrorReporting` is explicitly enabled.
**`disableSmartSort`** | `boolean` | _Optional_ | For the most-used and last-used app sort functions to work, a basic open-count is stored in local storage. If you do not want this to happen, then disable smart sort here, but you wil no longer be able to use these sort options. Defaults to `false`.
**`disableUpdateChecks`** | `boolean` | _Optional_ | If set to true, Dashy will not check for updates. Defaults to `false`.
**`enableServiceWorker`** | `boolean` | _Optional_ | Service workers cache web applications to improve load times and offer basic offline functionality, and are enabled by default in Dashy. The service worker can sometimes cause older content to be cached, requiring the app to be hard-refreshed. If you do not want SW functionality, or are having issues with caching, set this property to `true` to disable all service workers.
**`disableContextMenu`** | `boolean` | _Optional_ | If set to `true`, the custom right-click context menu will be disabled. Defaults to `false`.
**[⬆️ Back to Top](#configuring)**
### `appConfig.auth` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`users`** | `array` | _Optional_ | An array of objects containing usernames and hashed passwords. If this is not provided, then authentication will be off by default, and you will not need any credentials to access the app. See [`appConfig.auth.users`](#appconfigauthusers-optional). <br>**Note** this method of authentication is handled on the client side, so for security critical situations, it is recommended to use an [alternate authentication method](/docs/authentication.md#alternative-authentication-methods).
**`enableKeycloak`** | `boolean` | _Optional_ | If set to `true`, then authentication using Keycloak will be anabled. Note that you need to have an instance running, and have also configured `auth.keycloak`. Defaults to `false`
**`keycloak`** | `object` | _Optional_ | Config options to point Dashy to your Keycloak server. Requires `enableKeycloak: true`. See [`auth.keycloak`](#appconfigauthkeycloak-optional) for more info
**`enableGuestAccess`** | `boolean` | _Optional_ | When set to `true`, an unauthenticated user will be able to access the dashboard, with read-only access, without having to login. Requires `auth.users` to be configured. Defaults to `false`.
For more info, see the **[Authentication Docs](/docs/authentication.md)**
**[⬆️ Back to Top](#configuring)**
### `appConfig.auth.users` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`user`** | `string` | Required | Username to log in with
**`hash`** | `string` | Required | A SHA-256 hashed password
**`type`** | `string` | _Optional_ | The user type, either admin or normal
**[⬆️ Back to Top](#configuring)**
### `appConfig.auth.keycloak` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`serverUrl`** | `string` | Required | The URL (or URL/ IP + Port) where your keycloak server is running
**`realm`** | `string` | Required | The name of the realm (must already be created) that you want to use
**`clientId`** | `string` | Required | The Client ID of the client you created for use with Dashy
**[⬆️ Back to Top](#configuring)**
### `appConfig.webSearch` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`disableWebSearch`** | `string` | _Optional_ | Web search is enabled by default, but can be disabled by setting this property to `true`
**`searchEngine`** | `string` | _Optional_ | Set the key name for your search engine. Can also use a custom engine by setting this property to `custom`. Currently supported: `duckduckgo`, `google`, `whoogle`, `qwant`, `startpage`, `searx-bar` and `searx-info`. Defaults to `duckduckgo`
**`customSearchEngine`** | `string` | _Optional_ | You can also use a custom search engine, or your own self-hosted instance. This requires `searchEngine: custom` to be set. Then add the URL of your service, with GET query string included here
**`openingMethod`** | `string` | _Optional_ | Set your preferred opening method for search results: `newtab`, `sametab`, `workspace`. Defaults to `newtab`
**`searchBangs`** | `object` | _Optional_ | A key-value-pair set of custom search _bangs_ for redirecting query to a specific app or search engine. The key of each should be the bang you will type (typically starting with `/`, `!` or `:`), and value is the destination, either as a search engine key (e.g. `reddit`) or a URL with search parameters (e.g. `https://en.wikipedia.org/w/?search=`)
**[⬆️ Back to Top](#configuring)**
### `appConfig.hideComponents` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`hideHeading`** | `boolean` | _Optional_ | If set to `true`, the page title & sub-title will not be visible. Defaults to `false`
**`hideNav`** | `boolean` | _Optional_ | If set to `true`, the navigation menu will not be visible. Defaults to `false`
**`hideSearch`** | `boolean` | _Optional_ | If set to `true`, the search bar will not be visible. Defaults to `false`
**`hideSettings`** | `boolean` | _Optional_ | If set to `true`, the settings menu will not be visible. Defaults to `false`
**`hideFooter`** | `boolean` | _Optional_ | If set to `true`, the footer will not be visible. Defaults to `false`
**`hideSplashScreen`** | `boolean` | _Optional_ | If set to `true`, splash screen will not be visible while the app loads. Defaults to `true` (except on first load, when the loading screen is always shown)
**[⬆️ Back to Top](#configuring)**
### `section`
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`name`** | `string` | Required | The title for the section
**`icon`** | `string` | _Optional_ | An single icon to be displayed next to the title. See [`section.icon`](#sectionicon-and-sectionitemicon)
**`items`** | `array` | Required | An array of items to be displayed within the section. See [`item`](#sectionitem)
**`displayData`** | `object` | _Optional_ | Meta-data to optionally overide display settings for a given section. See [`displayData`](#sectiondisplaydata-optional)
**[⬆️ Back to Top](#configuring)**
### `section.item`
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`title`** | `string` | Required | The text to display/ title of a given item. Max length `18`
**`description`** | `string` | _Optional_ | Additional info about an item, which is shown in the tooltip on hover, or visible on large tiles
**`url`** | `string` | Required | The URL / location of web address for when the item is clicked
**`icon`** | `string` | _Optional_ | The icon for a given item. Can be a font-awesome icon, favicon, remote URL or local URL. See [`item.icon`](#sectionicon-and-sectionitemicon)
**`target`** | `string` | _Optional_ | The opening method for when the item is clicked, either `newtab`, `sametab`, `top`, `parent`, `modal` or `workspace`. Where `newtab` will open the link in a new tab, `sametab` will open it in the current tab, and `modal` will open a pop-up modal and `workspace` will open in the Workspace view. Defaults to `newtab`
**`hotkey`** | `number` | _Optional_ | Give frequently opened applications a numeric hotkey, between `0 - 9`. You can then just press that key to launch that application.
**`tags`** | `string[]` | _Optional_ | A list of tags, which can be used for improved search
**`statusCheck`** | `boolean` | _Optional_ | When set to `true`, Dashy will ping the URL associated with the current service, and display its status as a dot next to the item. The value here will override `appConfig.statusCheck` so you can turn off or on checks for a given service. Defaults to `appConfig.statusCheck`, falls back to `false`
**`statusCheckUrl`** | `string` | _Optional_ | If you've enabled `statusCheck`, and want to use a different URL to what is defined under the item, then specify it here
**`statusCheckHeaders`** | `object` | _Optional_ | If you're endpoint requires any specific headers for the status checking, then define them here
**`statusCheckAllowInsecure`** | `boolean` | _Optional_ | By default, any request to insecure content will be blocked. Setting this option to `true` will disable the `rejectUnauthorized` option, enabling you to ping non-HTTPS services for the current item. Defaults to `false`
**`color`** | `string` | _Optional_ | An optional color for the text and font-awesome icon to be displayed in. Note that this will override the current theme and so may not display well
**`backgroundColor`** | `string` | _Optional_ | An optional background fill color for the that given item. Again, this will override the current theme and so might not display well against the background
**`provider`** | `string` | _Optional_ | The name of the provider for a given service, useful for when including hosted apps. In some themes, this is visible under the item name
**[⬆️ Back to Top](#configuring)**
### `section.displayData` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`sortBy`** | `string` | _Optional_ | The sort order for items within the current section. By default items are displayed in the order in which they are listed in within the config. The following sort options are supported: `most-used` (most opened apps first), `last-used` (the most recently used apps), `alphabetical`, `reverse-alphabetical`, `random` and `default`
**`collapsed`** | `boolean` | _Optional_ | If true, the section will be collapsed initially, and will need to be clicked to open. Useful for less regularly used, or very long sections. Defaults to `false`
**`rows`** | `number` | _Optional_ | Height of the section, specified as the number of rows it should span vertically, e.g. `2`. Defaults to `1`. Max is `5`.
**`cols`** | `number` | _Optional_ | Width of the section, specified as the number of columns the section should span horizontally, e.g. `2`. Defaults to `1`. Max is `5`.
**`itemSize`** | `string` | _Optional_ | Specify the size for items within this group, either `small`, `medium` or `large`. Note that this will overide any settings specified through the UI
**`color`** | `string` | _Optional_ | A custom accent color for the section, as a hex code or HTML color (e.g. `#fff`)
**`customStyles`** | `string` | _Optional_ | Custom CSS properties that should be applied to that section, e.g. `border: 2px dashed #ff0000;`
**`sectionLayout`** | `string` | _Optional_ | Specify which CSS layout will be used to responsivley place items. Can be either `auto` (which uses flex layout), or `grid`. If `grid` is selected, then `itemCountX` and `itemCountY` may also be set. Defaults to `auto`
**`itemCountX`** | `number` | _Optional_ | The number of items to display per row / horizontally. If not set, it will be calculated automatically based on available space. Can only be set if `sectionLayout` is set to `grid`. Must be a whole number between `1` and `12`
**`itemCountY`** | `number` | _Optional_ | The number of items to display per column / vertically. If not set, it will be calculated automatically based on available space. If `itemCountX` is set, then `itemCountY` can be calculated automatically. Can only be set if `sectionLayout` is set to `grid`. Must be a whole number between `1` and `12`
**`hideForUsers`** | `string[]` | _Optional_ | Current section will be visible to all users, except for those specified in this list
**`showForUsers`** | `string[]` | _Optional_ | Current section will be hidden from all users, except for those specified in this list
**`hideForGuests`** | `boolean` | _Optional_ | Current section will be visible for logged in users, but not for guests (see `appConfig.enableGuestAccess`). Defaults to `false`
**[⬆️ Back to Top](#configuring)**
### `section.icon` and `section.item.icon`
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`icon`** | `string` | _Optional_ | The icon for a given item or section. See [Icon Docs](/docs/icons.md) for all available supported icon types. To auto-fetch icon from a services URL, aet to `favicon`. To use font-awesome, specify the category, followed by the icon name, e.g. `fas fa-rocket`, `fab fa-monero` or `fal fa-duck`. Similarly, for branded icons, you can use [simple-icons](https://simpleicons.org/) by setting icon to `si-[icon-name]` or [material-design-icons](https://dev.materialdesignicons.com/icons) by setting icon to `mdi-[icon-name]`. If set to `generative`, then a unique icon is generated from the apps URL or IP. You can also use hosted any by specifying it's URL, e.g. `https://i.ibb.co/710B3Yc/space-invader-x256.png`. To use a local image, first store it in `./public/item-icons/` (or `-v /app/public/item-icons/` in Docker) , and reference it by name and extension - e.g. set `image.png` to use `./public/item-icon/image.png`, you can also use sub-folders if you have a lot of icons, to keep them organised.
**[⬆️ Back to Top](#configuring)**
---
## Notes
### Editing Config through the UI
Config can be modified directly through the UI, and then written to disk, or applied locally. This can be done wither with the raw config editor (introduced in V 0.6.5 / [#3](https://github.com/Lissy93/dashy/pull/3)), or the interactive editor (introduced in V 1.8.9 / [#298](https://github.com/Lissy93/dashy/pull/298)).
<p align="center">
<a href="https://ibb.co/CzkyMNb">
<b>Interactive Editor</b><br>
<img alt="Interactive Editor demo" src="https://user-images.githubusercontent.com/1862727/139543020-b0576d28-0830-476f-afc8-a815d4de6def.gif" width="600" />
</a>
<br>
<a href="https://ibb.co/zRv542H">
<b>Raw Editor</b><br>
<img alt="Config Editor demo" src="https://raw.githubusercontent.com/Lissy93/dashy/master/docs/assets/config-editor-demo.gif" width="600" />
</a>
</p>
### About YAML
If you're new to YAML, it's pretty straight-forward. The format is exactly the same as that of JSON, but instead of using curly braces, structure is denoted using whitespace. This [quick guide](https://linuxhandbook.com/yaml-basics/) should get you up to speed in a few minutes, for more advanced topics take a look at this [Wikipedia article](https://en.wikipedia.org/wiki/YAML).
### Config Saving Methods
When updating the config through the JSON editor in the UI, you have two save options: **Local** or **Write to Disk**.
- Changes saved locally will only be applied to the current user through the browser, and will not apply to other instances - you either need to use the cloud sync feature, or manually update the conf.yml file.
- On the other-hand, if you choose to write changes to disk, then your main `conf.yml` file will be updated, and changes will be applied to all users, and visible across all devices. For this functionality to work, you must be running Dashy with using the Docker container, or the Node server. A backup of your current configuration will also be saved in the same directory.
### Preventing Changes being Written to Disk
To disallow any changes from being written to disk via the UI config editor, set `appConfig.allowConfigEdit: false`. If you are using users, and have setup `auth` within Dashy, then only users with `type: admin` will be able to write config changes to disk.
### Example
```yaml
---
pageInfo:
title: Home Lab
sections: # An array of sections
- name: Section 1 - Getting Started
items: # An array of items
- title: GitHub
description: Source code and documentation on GitHub
icon: fab fa-github
url: https://github.com/Lissy93/dashy
- title: Issues
description: View currently open issues, or raise a new one
icon: fas fa-bug
url: https://github.com/Lissy93/dashy/issues
- title: Demo
description: A live demo
icon: far fa-rocket
url: https://dashy-demo-1.netlify.app
- name: Section 2 - Local Services
items:
- title: Firewall
icon: favicon
url: http://192.168.1.1/
- title: Game Server
icon: https://i.ibb.co/710B3Yc/space-invader-x256.png
url: http://192.168.130.1/
```
For more example config files, see: [this gist](https://gist.github.com/Lissy93/000f712a5ce98f212817d20bc16bab10)
If you need any help, feel free to [Raise an Issue](https://github.com/Lissy93/dashy/issues/new?assignees=Lissy93&labels=%F0%9F%A4%B7%E2%80%8D%E2%99%82%EF%B8%8F+Question&template=question.md&title=%5BQUESTION%5D) or [Start a Discussion](https://github.com/Lissy93/dashy/discussions)
Happy Configuring 🤓🔧
**[⬆️ Back to Top](#configuring)**
# Configuring
All app configuration is specified in [`/public/conf.yml`](https://github.com/Lissy93/dashy/blob/master/public/conf.yml) which is in [YAML Format](https://yaml.org/) format. Changes can also be made [directly through the UI](#editing-config-through-the-ui) and previewed live, from here you can also export, backup, reset, validate and download your configuration file.
The following file provides a reference of all supported configuration options.
---
#### Contents
- [**`pageInfo`**](#pageinfo) - Header text, footer, title, navigation, etc
- [`navLinks`](#pageinfonavlinks-optional) - Navigation bar items and links
- [**`appConfig`**](#appconfig-optional) - Main application settings
- [`webSearch`](#appconfigwebsearch-optional) - Configure web search engine options
- [`hideComponents`](#appconfighidecomponents-optional) - Show/ hide page components
- [`auth`](#appconfigauth-optional) - Built-in authentication setup
- [`users`](#appconfigauthusers-optional) - Setup for simple auth
- [`keycloak`](#appconfigauthkeycloak-optional) - Auth using Keycloak
- [**`sections`**](#section) - List of sections
- [`displayData`](#sectiondisplaydata-optional) - Section display settings
- [`icon`](#sectionicon-and-sectionitemicon) - Icon for a section
- [`items`](#sectionitem) - List of items
- [`icon`](#sectionicon-and-sectionitemicon) - Icon for an item
- [**Notes**](#notes)
- [Editing Config through the UI](#editing-config-through-the-ui)
- [About YAML](#about-yaml)
- [Config Saving Methods](#config-saving-methods)
- [Preventing Changes](#preventing-changes-being-written-to-disk)
- [Example](#example)
---
Tips:
- You may find it helpful to look at some sample config files to get you started, a collection of which can be found [here](https://gist.github.com/Lissy93/000f712a5ce98f212817d20bc16bab10)
- You can check that your config file fits the schema, by running `yarn validate-config`
- After modifying your config, the app needs to be recompiled, by running `yarn build` - this happens automatically whilst the app is running if you're using Docker
- It is recommended to make and keep a backup of your config file. You can download your current config through the UI either from the Config menu, or using the `/download` endpoint. Alternatively, you can use the [Cloud Backup](./docs/backup-restore.md) feature.
- The config can also be modified directly through the UI, validated and written to the conf.yml file.
- All fields are optional, unless otherwise stated.
---
### Top-Level Fields
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`pageInfo`** | `object` | Required | Basic meta data like title, description, nav bar links, footer text. See [`pageInfo`](#pageinfo)
**`appConfig`** | `object` | _Optional_ | Settings related to how the app functions, including API keys and global styles. See [`appConfig`](#appconfig-optional)
**`sections`** | `array` | Required | An array of sections, each containing an array of items, which will be displayed as links. See [`section`](#section)
**[⬆️ Back to Top](#configuring)**
### `PageInfo`
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`title`** | `string` | Required | Your dashboard title, displayed in the header and browser tab
**`description`** | `string` | _Optional_ | Description of your dashboard, also displayed as a subtitle
**`navLinks`** | `array` | _Optional_ | Optional list of a maximum of 6 links, which will be displayed in the navigation bar. See [`navLinks`](#pageinfonavlinks-optional)
**`footerText`** | `string` | _Optional_ | Text to display in the footer (note that this will override the default footer content). This can also include HTML and inline CSS
**`logo`** | `string` | _Optional_ | The path to an image to display in the header (to the right of the title). This can be either local, where `/` is the root of `./public`, or any remote image, such as `https://i.ibb.co/yhbt6CY/dashy.png`. It's recommended to scale your image down, so that it doesn't impact load times
**[⬆️ Back to Top](#configuring)**
### `pageInfo.navLinks` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`title`** | `string` | Required | The text to display on the link button
**`path`** | `string` | Required | The URL to navigate to when clicked. Can be relative (e.g. `/about`) or absolute (e.g. `https://example.com` or `http://192.168.1.1`)
**[⬆️ Back to Top](#configuring)**
### `appConfig` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`language`** | `string` | _Optional_ | The 2 (or 4-digit) [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language, e.g. `en` or `en-GB`. This must be a language that the app has already been [translated](https://github.com/Lissy93/dashy/tree/master/src/assets/locales) into. If your language is unavailable, Dashy will fallback to English. By default Dashy will attempt to auto-detect your language, although this may not work on some privacy browsers.
**`startingView`** | `enum` | _Optional_ | Which page to load by default, and on the base page or domain root. You can still switch to different views from within the UI. Can be either `default`, `minimal` or `workspace`. Defaults to `default`
**`defaultOpeningMethod`** | `enum` | _Optional_ | The default opening method for items, if no `target` is specified for a given item. Can be either `newtab`, `sametab`, `modal`, `workspace`, `clipboard`, `top` or `parent`. Defaults to `newtab`
**`statusCheck`** | `boolean` | _Optional_ | When set to `true`, Dashy will ping each of your services and display their status as a dot next to each item. This can be overridden by setting `statusCheck` under each item. Defaults to `false`
**`statusCheckInterval`** | `boolean` | _Optional_ | The number of seconds between checks. If set to `0` then service will only be checked on initial page load, which is usually the desired functionality. If value is less than `10` you may experience a hit in performance. Defaults to `0`
**`webSearch`** | `object` | _Optional_ | Configuration options for the web search feature, set your default search engine, opening method or disable web search. See [`webSearch`](#appconfigwebsearch-optional)
**`backgroundImg`** | `string` | _Optional_ | Path to an optional full-screen app background image. This can be either remote (http) or local (/). Note that this will slow down initial load
**`enableFontAwesome`** | `boolean` | _Optional_ | Where `true` is enabled, if left blank font-awesome will be enabled only if required by 1 or more icons
**`fontAwesomeKey`** | `string` | _Optional_ | If you have a font-awesome key, then you can use it here and make use of premium icons. It is a 10-digit alpha-numeric string from you're FA kit URL (e.g. `13014ae648`)
**`faviconApi`** | `enum` | _Optional_ | Only applicable if you are using favicons for item icons. Specifies which service to use to resolve favicons. Set to `local` to do this locally, without using an API. Services running locally will use this option always. Available options are: `local`, `faviconkit`, `iconhorse`, `google`, `clearbit`, `webmasterapi` and `allesedv`. Defaults to `faviconkit`. See [Icons](/docs/icons.md#favicons) for more info
**`auth`** | `object` | _Optional_ | All settings relating to user authentication. See [`auth`](#appconfigauth-optional)
**`layout`** | `enum` | _Optional_ | Layout for homepage, either `horizontal`, `vertical` or `auto`. Defaults to `auto`. This specifies the layout and direction of how sections are positioned on the home screen. This can also be modified and overridden from the UI.
**`iconSize`** | `enum` | _Optional_ | The size of link items / icons. Can be either `small`, `medium,` or `large`. Defaults to `medium`. This can also be set directly from the UI.
**`colCount`** | `number` | _Optional_ | The number of columns of sections displayed on the homepage, using the default view. Should be in integer between `1` and `8`. Note that by default this is applied responsively, based on current screen size, and specifying a value here will override this behavior, which may not be desirable.
**`theme`** | `string` | _Optional_ | The default theme for first load (you can change this later from the UI)
**`cssThemes`** | `string[]` | _Optional_ | An array of custom theme names which can be used in the theme switcher dropdown
**`customColors`** | `object`| _Optional_ | Enables you to apply a custom color palette to any given theme. Use the theme name (lowercase) as the key, for an object including key-value-pairs, with the color variable name as keys, and 6-digit hex code as value. See [Theming](/docs/theming.md#modifying-theme-colors) for more info
**`externalStyleSheet`** | `string` or `string[]` | _Optional_ | Either a URL to an external stylesheet or an array or URLs, which can be applied as themes within the UI
**`customCss`** | `string` | _Optional_ | Raw CSS that will be applied to the page. This can also be set from the UI. Please minify it first.
**`hideComponents`** | `object` | _Optional_ | A list of key page components (header, footer, search, settings, etc) that are present by default, but can be removed using this option. See [`appConfig.hideComponents`](#appconfighideComponents-optional)
**`routingMode`** | `string` | _Optional_ | Can be either `hash` or `history`. Determines the URL format for sub-pages, hash mode will look like `/#/home` whereas with history mode available you have nice clean URLs, like `/home`. For more info, see the [Vue docs](https://router.vuejs.org/guide/essentials/history-mode.html#example-server-configurations). If you're hosting Dashy with a custom BASE_URL, you will find that a bit of extra server config is necessary to get history mode working, so here you may want to instead use `hash` mode.Defaults to `history`.
**`enableMultiTasking`** | `boolean` | _Optional_ | If set to true, will keep apps open in the background when in the workspace view. Useful for quickly switching between multiple sites, and preserving their state, but comes at the cost of performance.
**`workspaceLandingUrl`** | `string` | _Optional_ | The URL or an app, service or website to launch when the workspace view is opened, before another service has been launched
**`allowConfigEdit`** | `boolean` | _Optional_ | Should prevent / allow the user to write configuration changes to the conf.yml from the UI. When set to `false`, the user can only apply changes locally using the config editor within the app, whereas if set to `true` then changes can be written to disk directly through the UI. Defaults to `true`. Note that if authentication is enabled, the user must be of type `admin` in order to apply changes globally.
**`showSplashScreen`** | `boolean` | _Optional_ | If set to `true`, a loading screen will be shown. Defaults to `false`.
**`enableErrorReporting`** | `boolean` | _Optional_ | Enable reporting of unexpected errors and crashes. This is off by default, and **no data will ever be captured unless you explicitly enable it**. Turning on error reporting helps previously unknown bugs get discovered and fixed. Dashy uses [Sentry](https://github.com/getsentry/sentry) for error reporting. Defaults to `false`.
**`sentryDsn`** | `boolean` | _Optional_ | If you need to monitor errors in your instance, then you can use Sentry to collect and process bug reports. Sentry can be self-hosted, or used as SaaS, once your instance is setup, then all you need to do is pass in the DSN here, and enable error reporting. You can learn more on the [Sentry DSN Docs](https://docs.sentry.io/product/sentry-basics/dsn-explainer/). Note that this will only ever be used if `enableErrorReporting` is explicitly enabled.
**`disableSmartSort`** | `boolean` | _Optional_ | For the most-used and last-used app sort functions to work, a basic open-count is stored in local storage. If you do not want this to happen, then disable smart sort here, but you wil no longer be able to use these sort options. Defaults to `false`.
**`disableUpdateChecks`** | `boolean` | _Optional_ | If set to true, Dashy will not check for updates. Defaults to `false`.
**`enableServiceWorker`** | `boolean` | _Optional_ | Service workers cache web applications to improve load times and offer basic offline functionality, and are enabled by default in Dashy. The service worker can sometimes cause older content to be cached, requiring the app to be hard-refreshed. If you do not want SW functionality, or are having issues with caching, set this property to `true` to disable all service workers.
**`disableContextMenu`** | `boolean` | _Optional_ | If set to `true`, the custom right-click context menu will be disabled. Defaults to `false`.
**[⬆️ Back to Top](#configuring)**
### `appConfig.auth` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`users`** | `array` | _Optional_ | An array of objects containing usernames and hashed passwords. If this is not provided, then authentication will be off by default, and you will not need any credentials to access the app. See [`appConfig.auth.users`](#appconfigauthusers-optional). <br>**Note** this method of authentication is handled on the client side, so for security critical situations, it is recommended to use an [alternate authentication method](/docs/authentication.md#alternative-authentication-methods).
**`enableKeycloak`** | `boolean` | _Optional_ | If set to `true`, then authentication using Keycloak will be anabled. Note that you need to have an instance running, and have also configured `auth.keycloak`. Defaults to `false`
**`keycloak`** | `object` | _Optional_ | Config options to point Dashy to your Keycloak server. Requires `enableKeycloak: true`. See [`auth.keycloak`](#appconfigauthkeycloak-optional) for more info
**`enableGuestAccess`** | `boolean` | _Optional_ | When set to `true`, an unauthenticated user will be able to access the dashboard, with read-only access, without having to login. Requires `auth.users` to be configured. Defaults to `false`.
For more info, see the **[Authentication Docs](/docs/authentication.md)**
**[⬆️ Back to Top](#configuring)**
### `appConfig.auth.users` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`user`** | `string` | Required | Username to log in with
**`hash`** | `string` | Required | A SHA-256 hashed password
**`type`** | `string` | _Optional_ | The user type, either admin or normal
**[⬆️ Back to Top](#configuring)**
### `appConfig.auth.keycloak` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`serverUrl`** | `string` | Required | The URL (or URL/ IP + Port) where your keycloak server is running
**`realm`** | `string` | Required | The name of the realm (must already be created) that you want to use
**`clientId`** | `string` | Required | The Client ID of the client you created for use with Dashy
**[⬆️ Back to Top](#configuring)**
### `appConfig.webSearch` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`disableWebSearch`** | `string` | _Optional_ | Web search is enabled by default, but can be disabled by setting this property to `true`
**`searchEngine`** | `string` | _Optional_ | Set the key name for your search engine. Can also use a custom engine by setting this property to `custom`. Currently supported: `duckduckgo`, `google`, `whoogle`, `qwant`, `startpage`, `searx-bar` and `searx-info`. Defaults to `duckduckgo`
**`customSearchEngine`** | `string` | _Optional_ | You can also use a custom search engine, or your own self-hosted instance. This requires `searchEngine: custom` to be set. Then add the URL of your service, with GET query string included here
**`openingMethod`** | `string` | _Optional_ | Set your preferred opening method for search results: `newtab`, `sametab`, `workspace`. Defaults to `newtab`
**`searchBangs`** | `object` | _Optional_ | A key-value-pair set of custom search _bangs_ for redirecting query to a specific app or search engine. The key of each should be the bang you will type (typically starting with `/`, `!` or `:`), and value is the destination, either as a search engine key (e.g. `reddit`) or a URL with search parameters (e.g. `https://en.wikipedia.org/w/?search=`)
**[⬆️ Back to Top](#configuring)**
### `appConfig.hideComponents` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`hideHeading`** | `boolean` | _Optional_ | If set to `true`, the page title & sub-title will not be visible. Defaults to `false`
**`hideNav`** | `boolean` | _Optional_ | If set to `true`, the navigation menu will not be visible. Defaults to `false`
**`hideSearch`** | `boolean` | _Optional_ | If set to `true`, the search bar will not be visible. Defaults to `false`
**`hideSettings`** | `boolean` | _Optional_ | If set to `true`, the settings menu will be initially collapsed. Defaults to `false`
**`hideFooter`** | `boolean` | _Optional_ | If set to `true`, the footer will not be visible. Defaults to `false`
**[⬆️ Back to Top](#configuring)**
### `section`
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`name`** | `string` | Required | The title for the section
**`icon`** | `string` | _Optional_ | An single icon to be displayed next to the title. See [`section.icon`](#sectionicon-and-sectionitemicon)
**`items`** | `array` | _Optional_ | An array of items to be displayed within the section. See [`item`](#sectionitem). Sections must include either 1 or more items, or 1 or more widgets.
**`widgets`** | `array` | _Optional_ | An array of widgets to be displayed within the section. See [`widget`](#sectionwidget-optional)
**`displayData`** | `object` | _Optional_ | Meta-data to optionally overide display settings for a given section. See [`displayData`](#sectiondisplaydata-optional)
**[⬆️ Back to Top](#configuring)**
### `section.item`
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`title`** | `string` | Required | The text to display/ title of a given item. Max length `18`
**`description`** | `string` | _Optional_ | Additional info about an item, which is shown in the tooltip on hover, or visible on large tiles
**`url`** | `string` | Required | The URL / location of web address for when the item is clicked
**`icon`** | `string` | _Optional_ | The icon for a given item. Can be a font-awesome icon, favicon, remote URL or local URL. See [`item.icon`](#sectionicon-and-sectionitemicon)
**`target`** | `string` | _Optional_ | The opening method for when the item is clicked, either `newtab`, `sametab`, `modal`, `workspace`, `clipboard`, `top` or `parent`. Where `newtab` will open the link in a new tab, `sametab` will open it in the current tab, and `modal` will open a pop-up modal, `workspace` will open in the Workspace view and `clipboard` will copy the URL to system clipboard (but not launch app). Defaults to `newtab`
**`hotkey`** | `number` | _Optional_ | Give frequently opened applications a numeric hotkey, between `0 - 9`. You can then just press that key to launch that application.
**`tags`** | `string[]` | _Optional_ | A list of tags, which can be used for improved search
**`statusCheck`** | `boolean` | _Optional_ | When set to `true`, Dashy will ping the URL associated with the current service, and display its status as a dot next to the item. The value here will override `appConfig.statusCheck` so you can turn off or on checks for a given service. Defaults to `appConfig.statusCheck`, falls back to `false`
**`statusCheckUrl`** | `string` | _Optional_ | If you've enabled `statusCheck`, and want to use a different URL to what is defined under the item, then specify it here
**`statusCheckHeaders`** | `object` | _Optional_ | If you're endpoint requires any specific headers for the status checking, then define them here
**`statusCheckAllowInsecure`** | `boolean` | _Optional_ | By default, any request to insecure content will be blocked. Setting this option to `true` will disable the `rejectUnauthorized` option, enabling you to ping non-HTTPS services for the current item. Defaults to `false`
**`color`** | `string` | _Optional_ | An optional color for the text and font-awesome icon to be displayed in. Note that this will override the current theme and so may not display well
**`backgroundColor`** | `string` | _Optional_ | An optional background fill color for the that given item. Again, this will override the current theme and so might not display well against the background
**`provider`** | `string` | _Optional_ | The name of the provider for a given service, useful for when including hosted apps. In some themes, this is visible under the item name
**[⬆️ Back to Top](#configuring)**
### `section.widget` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`type`** | `string` | Required | The widget type. See [Widget Docs](/docs/widgets.md) for full list of supported widgets
**`options`** | `object` | _Optional_ | Some widgets accept either optional or required additional options. Again, see the [Widget Docs](/docs/widgets.md) for full list of options
**`updateInterval`** | `number` | _Optional_ | You can keep a widget constantly updated by specifying an update interval, in seconds. See [Continuous Updates Docs](/docs/widgets.md#continuous-updates) for more info
**`useProxy`** | `boolean` | _Optional_ | Some widgets make API requests to services that are not CORS-enabled. For these instances, you will need to route requests through a proxy, Dashy has a built in CORS-proxy, which you can use by setting this option to `true`. Defaults to `false`. See the [Proxying Requests Docs](/docs/widgets.md#proxying-requests) for more info
**[⬆️ Back to Top](#configuring)**
### `section.displayData` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`sortBy`** | `string` | _Optional_ | The sort order for items within the current section. By default items are displayed in the order in which they are listed in within the config. The following sort options are supported: `most-used` (most opened apps first), `last-used` (the most recently used apps), `alphabetical`, `reverse-alphabetical`, `random` and `default`
**`collapsed`** | `boolean` | _Optional_ | If true, the section will be collapsed initially, and will need to be clicked to open. Useful for less regularly used, or very long sections. Defaults to `false`
**`cutToHeight`** | `boolean` | _Optional_ | By default, sections will fill available space. Set this option to true to match section height with content height
**`rows`** | `number` | _Optional_ | Height of the section, specified as the number of rows it should span vertically, e.g. `2`. Defaults to `1`. Max is `5`.
**`cols`** | `number` | _Optional_ | Width of the section, specified as the number of columns the section should span horizontally, e.g. `2`. Defaults to `1`. Max is `5`.
**`itemSize`** | `string` | _Optional_ | Specify the size for items within this group, either `small`, `medium` or `large`. Note that this will overide any settings specified through the UI
**`color`** | `string` | _Optional_ | A custom accent color for the section, as a hex code or HTML color (e.g. `#fff`)
**`customStyles`** | `string` | _Optional_ | Custom CSS properties that should be applied to that section, e.g. `border: 2px dashed #ff0000;`
**`sectionLayout`** | `string` | _Optional_ | Specify which CSS layout will be used to responsivley place items. Can be either `auto` (which uses flex layout), or `grid`. If `grid` is selected, then `itemCountX` and `itemCountY` may also be set. Defaults to `auto`
**`itemCountX`** | `number` | _Optional_ | The number of items to display per row / horizontally. If not set, it will be calculated automatically based on available space. Can only be set if `sectionLayout` is set to `grid`. Must be a whole number between `1` and `12`
**`itemCountY`** | `number` | _Optional_ | The number of items to display per column / vertically. If not set, it will be calculated automatically based on available space. If `itemCountX` is set, then `itemCountY` can be calculated automatically. Can only be set if `sectionLayout` is set to `grid`. Must be a whole number between `1` and `12`
**`hideForUsers`** | `string[]` | _Optional_ | Current section will be visible to all users, except for those specified in this list
**`showForUsers`** | `string[]` | _Optional_ | Current section will be hidden from all users, except for those specified in this list
**`hideForGuests`** | `boolean` | _Optional_ | Current section will be visible for logged in users, but not for guests (see `appConfig.enableGuestAccess`). Defaults to `false`
**`hideForKeycloakUsers`** | `object` | _Optional_ | Current section will be visible to all keycloak users, except for those configured via these groups and roles. See `hideForKeycloakUsers`
**`showForKeycloakUsers`** | `object` | _Optional_ | Current section will be hidden from all keyclaok users, except for those configured via these groups and roles. See `showForKeycloakUsers`
**[⬆️ Back to Top](#configuring)**
### `section.icon` and `section.item.icon`
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`icon`** | `string` | _Optional_ | The icon for a given item or section. <br>See [Icon Docs](/docs/icons.md) for all available supported icon types, including: auto-fetched favicons, generative icons, emoji icons, home-lab service logos, font-awesome, simple-icons, material icons, and icons specified by URL
**[⬆️ Back to Top](#configuring)**
### `section.displayData.hideForKeycloakUsers` and `section.displayData.showForKeycloakUsers`
**Field** | **Type** | **Required**| **Description**
--- |------------| --- | ---
**`groups`** | `string[]` | _Optional_ | Current Section will be hidden or shown based on the user having any of the groups in this list
**`roles`** | `string[]` | _Optional_ | Current Section will be hidden or shown based on the user having any of the roles in this list
**[⬆️ Back to Top](#configuring)**
---
## Notes
### Editing Config through the UI
Config can be modified directly through the UI, and then written to disk, or applied locally. This can be done wither with the raw config editor (introduced in V 0.6.5 / [#3](https://github.com/Lissy93/dashy/pull/3)), or the interactive editor (introduced in V 1.8.9 / [#298](https://github.com/Lissy93/dashy/pull/298)).
<p align="center">
<a href="https://ibb.co/CzkyMNb">
<b>Interactive Editor</b><br>
<img alt="Interactive Editor demo" src="https://user-images.githubusercontent.com/1862727/139543020-b0576d28-0830-476f-afc8-a815d4de6def.gif" width="600" />
</a>
<br>
<a href="https://ibb.co/zRv542H">
<b>JSON Editor</b><br>
<img alt="Config Editor demo" src="https://raw.githubusercontent.com/Lissy93/dashy/master/docs/assets/config-editor-demo.gif" width="600" />
</a>
</p>
### About YAML
If you're new to YAML, it's pretty straight-forward. The format is exactly the same as that of JSON, but instead of using curly braces, structure is denoted using whitespace. This [quick guide](https://linuxhandbook.com/yaml-basics/) should get you up to speed in a few minutes, for more advanced topics take a look at this [Wikipedia article](https://en.wikipedia.org/wiki/YAML).
### Config Saving Methods
When updating the config through the JSON editor in the UI, you have two save options: **Local** or **Write to Disk**.
- Changes saved locally will only be applied to the current user through the browser, and will not apply to other instances - you either need to use the cloud sync feature, or manually update the conf.yml file.
- On the other-hand, if you choose to write changes to disk, then your main `conf.yml` file will be updated, and changes will be applied to all users, and visible across all devices. For this functionality to work, you must be running Dashy with using the Docker container, or the Node server. A backup of your current configuration will also be saved in the same directory.
### Preventing Changes being Written to Disk
To disallow any changes from being written to disk via the UI config editor, set `appConfig.allowConfigEdit: false`. If you are using users, and have setup `auth` within Dashy, then only users with `type: admin` will be able to write config changes to disk.
### Example
```yaml
---
pageInfo:
title: Home Lab
sections: # An array of sections
- name: Section 1 - Getting Started
items: # An array of items
- title: GitHub
description: Source code and documentation on GitHub
icon: fab fa-github
url: https://github.com/Lissy93/dashy
- title: Issues
description: View currently open issues, or raise a new one
icon: fas fa-bug
url: https://github.com/Lissy93/dashy/issues
- title: Demo
description: A live demo
icon: far fa-rocket
url: https://dashy-demo-1.netlify.app
- name: Section 2 - Local Services
items:
- title: Firewall
icon: favicon
url: http://192.168.1.1/
- title: Game Server
icon: https://i.ibb.co/710B3Yc/space-invader-x256.png
url: http://192.168.130.1/
```
For more example config files, see: [this gist](https://gist.github.com/Lissy93/000f712a5ce98f212817d20bc16bab10)
If you need any help, feel free to [Raise an Issue](https://github.com/Lissy93/dashy/issues/new?assignees=Lissy93&labels=%F0%9F%A4%B7%E2%80%8D%E2%99%82%EF%B8%8F+Question&template=question.md&title=%5BQUESTION%5D) or [Start a Discussion](https://github.com/Lissy93/dashy/discussions)
Happy Configuring 🤓🔧
**[⬆️ Back to Top](#configuring)**

View File

@ -1,14 +1,8 @@
# Contributing
First off, thank you for considering contributing towards Dashy! 🙌
There are several ways that you can help out (but don't feel you have to).
Any contributions, however small will always be very much appreciated, and you will be appropriately credited in the readme - huge thank you to [everyone who has helped](/docs/credits.md) so far 💞
## Submit a PR
Contributing to the code or documentation is super helpful. You can fix a bug, add a new feature or improve an existing one. I've written [several guides](https://github.com/Lissy93/dashy/blob/master/docs/development-guides.md) to help you get started. For setting up the development environment, outline of the standards, and understanding the PR flow, see the [Development Docs](https://github.com/Lissy93/dashy/blob/master/docs/development.md). I've tried to keep the code neat and documentation thorough, so understanding what everything does should be fairly straight forward, but feel free to ask if you have any questions.
## Add Translations
If you speak another language, then adding translations would be really helpful, and you will be credited in the readme for your work. Multi-language support makes Dashy accessible for non-English speakers, which I feel is important. This is a very quick and easy task, as all application text is located in [`locales/en.json`](https://github.com/Lissy93/dashy/blob/master/src/assets/locales/en.json), so adding a new language is as simple as copying this file and translating the values. You don't have to translate it all, as any missing attributes will just fallback to English. For a full tutorial, see the [Multi-Language Support Docs](https://github.com/Lissy93/dashy/blob/master/docs/multi-language-support.md).
There are several ways that you can help out, and any contributions, however small will always be very much appreciated.
You will be appropriately credited in the readme - huge thank you to [everyone who has helped](/docs/credits.md) so far 💞
## Take a 2-minute survey
Help improve Dashy by taking a very short, 6-question survey. This will give me a better understanding of what is important to you, so that I can make Dashy better in the future :)
@ -16,51 +10,18 @@ Help improve Dashy by taking a very short, 6-question survey. This will give me
[![Take the Survey](https://img.shields.io/badge/Take_the-Survey-%231a86fd?style=for-the-badge&logo=buddy)](https://survey.typeform.com/to/gl0L68ou)
## Share your dashboard
Dashy now has a [Showcase](https://github.com/Lissy93/dashy/blob/master/docs/showcase.md#dashy-showcase-) where you can show off a screenshot of your dashboard, and get inspiration from other users. I also really enjoy seeing how people are using Dashy. To [submit your dashboard](https://github.com/Lissy93/dashy/blob/master/docs/showcase.md#submitting-your-dashboard), please either open a PR or raise an issue.
Dashy now has a [Showcase](https://github.com/Lissy93/dashy/blob/master/docs/showcase.md#dashy-showcase-) where you can show off a screenshot of your dashboard, and get inspiration from other users (and I really love seeing how people are using Dashy). To [submit your dashboard](https://github.com/Lissy93/dashy/blob/master/docs/showcase.md#submitting-your-dashboard), either open a PR or raise an issue.
## Improve the Docs
Found a typo, or something that isn't as clear as it could be? Maybe I've missed something off altogether, or you hit a roadblock that took you a while to figure out. Submitting a pull request to add to or improve the documentation will help future users get Dashy up and running more easily.
All content is located either in the [`./README.md`](/README.md) or [`/docs/`](/docs) directory, and synced to the Wiki and website using a GH [action](/actions/workflows/wiki-sync.yml).
## Raise a bug
If you've found a bug, then please do raise it as an issue. This will help me know if there's something that needs fixing. Try and include as much detail as possible, such as your environment, steps to reproduce, any console output and maybe an example screenshot or recording if necessary.
[![Raise a Bug](https://img.shields.io/badge/Raise_a-Bug-%23dc2d76?style=for-the-badge&logo=dependabot)](https://github.com/Lissy93/dashy/issues/new?assignees=lissy93&labels=%F0%9F%90%9B+Bug&template=bug.yml&title=%5BBUG%5D+%3Ctitle%3E)
## Join the discussion
I've enabled the discussion feature on GitHub, here you can share tips and tricks, useful information, or your dashboard. You can also ask questions, and offer basic support to other users.
[![Join the Discussion on GitHub](https://img.shields.io/badge/Join_the-Discussion-%23ffd000?style=for-the-badge&logo=livechat)](https://github.com/Lissy93/dashy/discussions)
## Spread the word
Dashy is still a relatively young project, and as such not many people know of it. It would be great to see more users, and so it would be awesome if you could consider sharing on social platforms.
[![Share Dashy on Mastodon](https://img.shields.io/badge/Share-Mastodon-%232b90d9?style=for-the-badge&logo=mastodon)](https://mastodon.social/?text=Check%20out%20Dashy%2C%20the%20privacy-friendly%2C%20self-hosted%20startpage%20for%20organizing%20your%20life%3A%20https%3A%2F%2Fgithub.com%2FLissy93%2Fdashy%20-%20By%20%40lissy93%40mastodon.social)
[![Share Dashy on Reddit](https://img.shields.io/badge/Share-Reddit-%23FF5700?style=for-the-badge&logo=reddit)](http://www.reddit.com/submit?url=https://github.com/Lissy93/dashy&title=Dashy%20-%20The%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy on Twitter](https://img.shields.io/badge/Share-Twitter-%231DA1F2?style=for-the-badge&logo=twitter)](https://twitter.com/intent/tweet?url=https://github.com/lissy93/dashy&text=Check%20out%20Dashy%20by%20@Lissy_Sykes,%20the%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy on Facebook](https://img.shields.io/badge/Share-Facebook-%234267B2?style=for-the-badge&logo=facebook)](https://www.facebook.com/sharer/sharer.php?u=https://github.com/lissy93/dashy)
[![Share Dashy on LinkedIn](https://img.shields.io/badge/Share-LinkedIn-%230077b5?style=for-the-badge&logo=linkedin)](https://www.linkedin.com/shareArticle?mini=true&url=https://github.com/lissy93/dashy)
[![Share Dashy on Pinterest](https://img.shields.io/badge/Share-Pinterest-%23E60023?style=for-the-badge&logo=pinterest)](https://pinterest.com/pin/create/button/?url=https://github.com/lissy93/dashy&media=https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/1-home-lab-material.png&description=Check%20out%20Dashy,%20the%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy on VK](https://img.shields.io/badge/Share-VK-%234C75A3?style=for-the-badge&logo=vk)](https://vk.com/share.php?url=https%3A%2F%2Fgithub.com%2Flissy93%2Fdashy%2F&title=Check%20out%20Dashy%20-%20The%20Self-Hosted%20Dashboard%20for%20your%20Homelab%20%F0%9F%9A%80)
[![Share Dashy via Viber](https://img.shields.io/badge/Share-Viber-%238176d6?style=for-the-badge&logo=viber)](viber://forward?text=https%3A%2F%2Fgithub.com%2Flissy93%2Fdashy%0ACheck%20out%20Dashy%2C%20the%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy via Telegram](https://img.shields.io/badge/Share-Telegram-%230088cc?style=for-the-badge&logo=telegram)](https://t.me/share/url?url=https%3A%2F%2Fgithub.com%2Flissy93%2Fdashy&text=Check%20out%20Dashy%2C%20the%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy via Email](https://img.shields.io/badge/Share-Email-%238A90C7?style=for-the-badge&logo=protonmail)](mailto:info@example.com?&subject=Check%20out%20Dashy%20-%20The%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80&cc=&bcc=&body=https://github.com/lissy93/dashy)
## Star, Upvote or Leave a Review
Dashy is on the following platforms, and if you could spare a few seconds to give it an upvote or review, this will also help new users find it.
[![ProductHunt](https://img.shields.io/badge/Review-ProductHunt-%23b74424?style=for-the-badge&logo=producthunt)](https://www.producthunt.com/posts/dashy)
[![AlternativeTo](https://img.shields.io/badge/Review-AlternativeTo-%235581a6?style=for-the-badge&logo=abletonlive)](https://alternativeto.net/software/dashy/about/)
[![Slant](https://img.shields.io/badge/Review-Slant-%2346a1df?style=for-the-badge&logo=capacitor)](https://www.slant.co/improve/topics/27783/viewpoints/1/~self-hosted-homelab-startpage~dashy)
[![Star on GitHub](https://img.shields.io/github/stars/Lissy93/Dashy?color=ba96d6&label=Star%20-%20GitHub&logo=github&style=for-the-badge)](https://github.com/Lissy93/dashy/stargazers)
[![Star on DockerHub](https://img.shields.io/docker/stars/lissy93/dashy?color=4cb6e0&label=Star%20-%20Docker&logo=docker&style=for-the-badge)](https://hub.docker.com/r/lissy93/dashy)
[![Add your Dashboard to the Showcase](https://img.shields.io/badge/Add_your_Dashboard-Showcase-%238616ee?style=for-the-badge&logo=feathub&logoColor=8616ee)](https://github.com/Lissy93/dashy/issues/new?assignees=&labels=%F0%9F%92%AF+Showcase&template=showcase-addition.yml&title=%5BSHOWCASE%5D+%3Ctitle%3E)
## Make a small donation
Please only do this is you can definitely afford to. Don't feel any pressure to donate anything, as Dashy and my other projects will always be 100% free, for everyone, for ever.
[![Sponsor Lissy93 on GitHub](./assets/sponsor-button.svg)](https://github.com/sponsors/Lissy93)
Donations help to cover server costs, development time and caffeine ;)
Don't feel any pressure to donate anything, as Dashy and my other projects will always be 100% free, for everyone, for ever.
Sponsoring will give you several perks, from $1 / £0.70 per month, as well as a sponsor badge on your profile, you can also be credited on the readme, with a link to your website/ profile/ socials, get priority support, have your feature ideas implemented, plus lots more. For more info, see [@Lissy93's Sponsor Page](https://github.com/sponsors/Lissy93).
[![Sponsor Lissy93 on GitHub](https://img.shields.io/badge/Sponsor_on_GitHub-Lissy93-%23ff4dda?style=for-the-badge&logo=githubsponsors&logoColor=ff4dda)](https://github.com/sponsors/Lissy93)
Sponsoring will give you several perks - for $1 / £0.75 per month, you'll get a sponsor badge on your profile, be credited on the Dashy's readme, with a link to your website/ profile/ socials, get priority support, have your feature ideas implemented, plus lots more. For more info, see [@Lissy93's Sponsor Page](https://github.com/sponsors/Lissy93).
<details>
<summary>You can also send a one-off small contribution using crypto</summary>
@ -77,13 +38,9 @@ Sponsoring will give you several perks, from $1 / £0.70 per month, as well as a
</p>
</details>
## Request a feature via BountySource
BountySource is a platform for sponsoring the development of certain features on open source projects. If there is a feature you'd like implemented into Dashy, but either isn't high enough priority or is deemed to be more work than it's worth, then you can instead contribute a bounty towards it's development. You won't pay a penny until your proposal is fully built, and you are satisfied with the result. This helps support the developers, and makes Dashy better for everyone.
[![Request a Feature on BountySource](https://img.shields.io/badge/BountySource-Dashy-%23F67909?style=for-the-badge&logo=openbugbounty)](https://www.bountysource.com/teams/dashy)
## Enable Anonymous Bug Reports
[Sentry](https://github.com/getsentry/sentry) is an open source error tracking and performance monitoring tool, which enables the identification any errors which occur in the production app (only if you enable it). It helps me to discover bugs I was unaware of, and then fix them, in order to make Dashy more reliable long term. This is a simple, yet really helpful step you can take to help improve Dashy.
Bug reports helps me to discover bugs I was unaware of, and then fix them, in order to make Dashy more reliable long term. This is a simple, yet really helpful step you can take to help improve Dashy. [Sentry](https://github.com/getsentry/sentry) is an open source error tracking and performance monitoring tool, which enables the identification any errors which occur in the production app (only if you enable it).
To enable error reporting:
```yaml
@ -91,7 +48,55 @@ appConfig:
enableErrorReporting: true
```
All reporting is **disabled** by default, and no data will ever be sent to any external endpoint without your explicit consent. In fact, the error tracking package will not even be imported unless you have actively enabled it. All statistics are anonomized and stored securely. For more about privacy and security, see the [Sentry Docs](https://sentry.io/security/).
All reporting is **disabled** by default, and no data will ever be sent to any external endpoint without your explicit consent. All statistics are anonomized and stored securely. For more about privacy and security, see the [Sentry Security Docs](https://sentry.io/security/).
## Add Translations
If you speak another language, then adding translations will help make Dashy available to non-native English speakers. This is a very quick and easy task, as all application text is located in [`locales/en.json`](https://github.com/Lissy93/dashy/blob/master/src/assets/locales/en.json), so adding a new language is as simple as copying this file and translating the values. You don't have to translate it all, as any missing attributes will just fallback to English. For a full tutorial, see the [Multi-Language Support Docs](https://github.com/Lissy93/dashy/blob/master/docs/multi-language-support.md).
## Submit a PR
Contributing to the code or docs is super helpful. You can fix a bug, add a new feature or improve an existing one. If you've built your own custom widget, theme or view, consider sharing it in a PR. I've written [several guides](/docs/development-guides.md) to help you get started, and the steps for setting up the development environment are outlined in the [Development Docs](/docs/development.md). Feel free to ask if you have any questions.
## Improve the Docs
Found a typo, or something that isn't as clear as it could be? Maybe I've missed something off altogether, or you hit a roadblock that took you a while to figure out. Submitting a pull request to add to or improve the documentation will help future users get Dashy up and running more easily.
All content is located either in the [`./README.md`](/README.md) or [`/docs/`](/docs) directory, and synced to the Wiki and website using a GH [action](/actions/workflows/wiki-sync.yml).
## Raise a bug
If you've found a bug, then please do raise it as an issue. This will help me know if there's something that needs fixing. Try and include as much detail as possible, such as your environment, steps to reproduce, any console output and maybe an example screenshot or recording if necessary.
[![Raise a Bug](https://img.shields.io/badge/Raise_a-Bug-%23dc2d76?style=for-the-badge&logo=dependabot)](https://github.com/Lissy93/dashy/issues/new?assignees=lissy93&labels=%F0%9F%90%9B+Bug&template=bug.yml&title=%5BBUG%5D+%3Ctitle%3E)
## Join the discussion
I've enabled the discussion feature on GitHub, here you can share tips and tricks, useful information, or your dashboard. You can also ask questions, and offer basic support to other users.
[![Join the Discussion on GitHub](https://img.shields.io/badge/Join_the-Discussion-%23ffd000?style=for-the-badge&logo=livechat)](https://github.com/Lissy93/dashy/discussions)
## Request a feature via BountySource
BountySource is a platform for sponsoring the development of certain features on open source projects. If there is a feature you'd like implemented into Dashy, but either isn't high enough priority or is deemed to be more work than it's worth, then you can instead contribute a bounty towards it's development. You won't pay a penny until your proposal is fully built, and you are satisfied with the result. This helps support the developers, and makes Dashy better for everyone.
[![Request a Feature on BountySource](https://img.shields.io/badge/BountySource-Dashy-%23F67909?style=for-the-badge&logo=openbugbounty)](https://www.bountysource.com/teams/dashy)
## Spread the word
Dashy is still a relatively young project, and as such not many people know of it. It would be great to see more users, and so it would be awesome if you could consider sharing with your friends or on social platforms.
[![Share Dashy on Mastodon](https://img.shields.io/badge/Share-Mastodon-%232b90d9?style=flat-square&logo=mastodon)](https://mastodon.social/?text=Check%20out%20Dashy%2C%20the%20privacy-friendly%2C%20self-hosted%20startpage%20for%20organizing%20your%20life%3A%20https%3A%2F%2Fgithub.com%2FLissy93%2Fdashy%20-%20By%20%40lissy93%40mastodon.social)
[![Share Dashy on Reddit](https://img.shields.io/badge/Share-Reddit-%23FF5700?style=flat-square&logo=reddit)](http://www.reddit.com/submit?url=https://github.com/Lissy93/dashy&title=Dashy%20-%20The%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy on Twitter](https://img.shields.io/badge/Share-Twitter-%231DA1F2?style=flat-square&logo=twitter)](https://twitter.com/intent/tweet?url=https://github.com/lissy93/dashy&text=Check%20out%20Dashy%20by%20@Lissy_Sykes,%20the%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy on Facebook](https://img.shields.io/badge/Share-Facebook-%234267B2?style=flat-square&logo=facebook)](https://www.facebook.com/sharer/sharer.php?u=https://github.com/lissy93/dashy)
[![Share Dashy on LinkedIn](https://img.shields.io/badge/Share-LinkedIn-%230077b5?style=flat-square&logo=linkedin)](https://www.linkedin.com/shareArticle?mini=true&url=https://github.com/lissy93/dashy)
[![Share Dashy on Pinterest](https://img.shields.io/badge/Share-Pinterest-%23E60023?style=flat-square&logo=pinterest)](https://pinterest.com/pin/create/button/?url=https://github.com/lissy93/dashy&media=https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/1-home-lab-material.png&description=Check%20out%20Dashy,%20the%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy on VK](https://img.shields.io/badge/Share-VK-%234C75A3?style=flat-square&logo=vk)](https://vk.com/share.php?url=https%3A%2F%2Fgithub.com%2Flissy93%2Fdashy%2F&title=Check%20out%20Dashy%20-%20The%20Self-Hosted%20Dashboard%20for%20your%20Homelab%20%F0%9F%9A%80)
[![Share Dashy via Viber](https://img.shields.io/badge/Share-Viber-%238176d6?style=flat-square&logo=viber)](viber://forward?text=https%3A%2F%2Fgithub.com%2Flissy93%2Fdashy%0ACheck%20out%20Dashy%2C%20the%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy via Telegram](https://img.shields.io/badge/Share-Telegram-%230088cc?style=flat-square&logo=telegram)](https://t.me/share/url?url=https%3A%2F%2Fgithub.com%2Flissy93%2Fdashy&text=Check%20out%20Dashy%2C%20the%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80)
[![Share Dashy via Email](https://img.shields.io/badge/Share-Email-%238A90C7?style=flat-square&logo=protonmail)](mailto:info@example.com?&subject=Check%20out%20Dashy%20-%20The%20self-hosted%20dashboard%20for%20your%20homelab%20%F0%9F%9A%80&cc=&bcc=&body=https://github.com/lissy93/dashy)
## Star, Upvote or Leave a Review
Dashy is on the following platforms, and if you could spare a few seconds to give it an upvote or review, this will also help new users discover Dashy
[![ProductHunt](https://img.shields.io/badge/Review-ProductHunt-%23b74424?style=flat-square&logo=producthunt)](https://www.producthunt.com/posts/dashy)
[![AlternativeTo](https://img.shields.io/badge/Review-AlternativeTo-%235581a6?style=flat-square&logo=abletonlive)](https://alternativeto.net/software/dashy/about/)
[![Slant](https://img.shields.io/badge/Review-Slant-%2346a1df?style=flat-square&logo=capacitor)](https://www.slant.co/improve/topics/27783/viewpoints/1/~self-hosted-homelab-startpage~dashy)
[![Star on GitHub](https://img.shields.io/github/stars/Lissy93/Dashy?color=ba96d6&label=Star%20-%20GitHub&logo=github&style=flat-square)](https://github.com/Lissy93/dashy/stargazers)
[![Star on DockerHub](https://img.shields.io/docker/stars/lissy93/dashy?color=4cb6e0&label=Star%20-%20Docker&logo=docker&style=flat-square)](https://hub.docker.com/r/lissy93/dashy)
## Follow for More
If you've enjoyed Dashy, you can follow the me to get updates about other projects that I am working on.
@ -100,11 +105,11 @@ If you've enjoyed Dashy, you can follow the me to get updates about other projec
[![Alicia Sykes on GitHub](https://img.shields.io/github/followers/lissy93?label=Lissy93&style=social)](https://github.com/Lissy93)
[![Alicia Sykes on Mastodon](https://img.shields.io/mastodon/follow/1032965?domain=https%3A%2F%2Fmastodon.social)](https://mastodon.social/web/accounts/1032965)
[![Alicia Sykes on Keybase](https://img.shields.io/badge/aliciasykes--lightgrey?style=social&logo=Keybase)](https://keybase.io/aliciasykes)
[![Alicia Sykes's PGP](https://img.shields.io/badge/PGP--lightgrey?style=social&logo=Let%E2%80%99s%20Encrypt)](https://keybase.io/aliciasykes/pgp_keys.asc)
[![Alicia Sykes's Website](https://img.shields.io/badge/aliciasykes.com--lightgrey?style=social&logo=Tencent%20QQ)](https://aliciasykes.com)
[![Alicia Sykes's Blog](https://img.shields.io/badge/Blog--lightgrey?style=social&logo=micro.blog)](https://notes.aliciasykes.com/)
[![Alicia Sykes's PGP](https://img.shields.io/badge/PGP--lightgrey?style=social&logo=Let%E2%80%99s%20Encrypt)](https://keybase.io/aliciasykes/pgp_keys.asc)
If you like, you could also consider [subscribing to my mailing list](https://notes.aliciasykes.com/subscribe) for very occasional blog post updates.
If you like, you could also consider [subscribing to my mailing list](https://notes.aliciasykes.com/subscribe) for occasional blog post updates.
---

View File

@ -13,7 +13,7 @@
</td>
<td align="center">
<a href="https://github.com/Robert-Ernst">
<img src="https://avatars.githubusercontent.com/u/9050259?v=4" width="80;" alt="Robert-Ernst"/>
<img src="https://avatars.githubusercontent.com/u/9050259?u=7253b4063f1ffe3b5a894263c8b2056151802508&v=4" width="80;" alt="Robert-Ernst"/>
<br />
<sub><b>Robert Ernst</b></sub>
</a>
@ -26,10 +26,17 @@
</a>
</td>
<td align="center">
<a href="https://github.com/matthewjdegarmo">
<img src="https://avatars.githubusercontent.com/u/46715299?u=65f979e86de9dce8a5fe04df9775d32c6cefd73a&v=4" width="80;" alt="matthewjdegarmo"/>
<a href="https://github.com/KierenConnell">
<img src="https://avatars.githubusercontent.com/u/46445781?u=5502f8fb780938e2825735d7bbb9236642d212c0&v=4" width="80;" alt="KierenConnell"/>
<br />
<sub><b>Matthew J. DeGarmo</b></sub>
<sub><b>Kieren Connell</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ratty222">
<img src="https://avatars.githubusercontent.com/u/92832598?v=4" width="80;" alt="ratty222"/>
<br />
<sub><b>Ratty222</b></sub>
</a>
</td></tr>
</table>
@ -96,13 +103,6 @@
<sub><b>ᗪєνιη ᗷυнʟ</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/daentech">
<img src="https://avatars.githubusercontent.com/u/358678?v=4" width="80;" alt="daentech"/>
<br />
<sub><b>Dan Gilbert</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/BOZG">
<img src="https://avatars.githubusercontent.com/u/6022344?v=4" width="80;" alt="BOZG"/>
@ -110,6 +110,13 @@
<sub><b>Stephen Rigney</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/daentech">
<img src="https://avatars.githubusercontent.com/u/358678?v=4" width="80;" alt="daentech"/>
<br />
<sub><b>Dan Gilbert</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/BeginCI">
<img src="https://avatars.githubusercontent.com/u/57495754?v=4" width="80;" alt="BeginCI"/>
@ -146,13 +153,28 @@
<sub><b>Iaroslav Dronskii</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/b1thunt3r">
<img src="https://avatars.githubusercontent.com/u/791091?v=4" width="80;" alt="b1thunt3r"/>
<br />
<sub><b>Ishan Jain</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/KierenConnell">
<img src="https://avatars.githubusercontent.com/u/46445781?v=4" width="80;" alt="KierenConnell"/>
<br />
<sub><b>Kieren Connell</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/rubjo">
<img src="https://avatars.githubusercontent.com/u/42270947?v=4" width="80;" alt="rubjo"/>
<br />
<sub><b>Rubjo</b></sub>
</a>
</td>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/turnrye">
<img src="https://avatars.githubusercontent.com/u/701035?v=4" width="80;" alt="turnrye"/>
@ -166,8 +188,14 @@
<br />
<sub><b>Shreya Roy</b></sub>
</a>
</td></tr>
<tr>
</td>
<td align="center">
<a href="https://github.com/XertDev">
<img src="https://avatars.githubusercontent.com/u/16572811?v=4" width="80;" alt="XertDev"/>
<br />
<sub><b>Xert</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jnach">
<img src="https://avatars.githubusercontent.com/u/33467747?v=4" width="80;" alt="jnach"/>
@ -299,6 +327,7 @@ At it's core, the application uses [**Vue.js**](https://github.com/vuejs/vue), a
- [`axios`](https://github.com/axios/axios) - Promise based HTTP client by @mzabriskie and community `MIT`
- [`ajv`](https://github.com/ajv-validator/ajv) - JSON schema Validator by @epoberezkin and community `MIT`
- [`i18n`](https://github.com/kazupon/vue-i18n) - Internationalization plugin by @kazupon and community `MIT`
- [`frappe-charts`](https://github.com/frappe/charts) - Lightweight charting library by @frappe `MIT`
#### Frontend Components
- [`vue-select`](https://github.com/sagalbot/vue-select) - Dropdown component by @sagalbot `MIT`

View File

@ -16,6 +16,8 @@ Once you've got Dashy up and running, you'll want to configure it with your own
- [Deploy with Docker](#deploy-with-docker)
- [Using Docker Compose](#using-docker-compose)
- [Unraid](#unraid)
- [Synology NAS](#synology-nas)
- [Build from Source](#build-from-source)
- [Hosting with CDN](#hosting-with-cdn)
- [Run as executable](#run-as-executable)
@ -23,7 +25,9 @@ Once you've got Dashy up and running, you'll want to configure it with your own
- [Deploy to cloud service](#deploy-to-cloud-service)
- [Use managed instance](#use-managed-instance)
### Deploy with Docker
---
## Deploy with Docker
**Container Info**: [
![Docker Supported Architecture](https://img.shields.io/badge/Architectures-amd64%20|%20arm32v7%20|%20arm64v8-6ba6e5)
@ -66,7 +70,9 @@ If you're deploying Dashy on a modern ARM-based board, such as a Raspberry Pi (2
The image defaults to `:latest`, but you can instead specify a specific version, e.g. `docker pull lissy93/dashy:release-1.5.0`
### Using Docker Compose
---
## Using Docker Compose
Using Docker Compose can be useful for saving your specific config in files, without having to type out a long run command each time. Save compose config as a YAML file, and then run `docker compose up -d` (optionally use the `-f` flag to specify file location, if it isn't located at `./docker-compose.yml`), `-d` is detached mode (not running in the foreground of your terminal). Compose is also useful if you are using clusters, as the format is very similar to stack files, used with Docker Swarm.
@ -106,7 +112,21 @@ You can use a different tag, by for example setting `image: lissy93/dashy:arm64v
If you are building from source, and would like to use one of the [other Dockerfiles](https://github.com/Lissy93/dashy/tree/master/docker), then under `services.dashy` first set `context: .`, then specify the the path to the dockerfile, e.g. `dockerfile: ./docker/Dockerfile-arm32v7`
### Build from Source
---
## Unraid
// TODO
---
## Synology NAS
// TODO
---
## Build from Source
If you do not want to use Docker, you can run Dashy directly on your host system. For this, you will need both [git](https://git-scm.com/downloads) and the latest or LTS version of [Node.js](https://nodejs.org/) installed, and optionally [yarn](https://yarnpkg.com/)
@ -116,6 +136,8 @@ If you do not want to use Docker, you can run Dashy directly on your host system
4. Build: `yarn build`
5. Run: `yarn start`
---
### Deploy to cloud service
If you don't have a home server, then fear not - Dashy can be deployed to pretty much any cloud provider. The above Docker and NPM guides will work exactly the same on a VPS, but I've also setup some 1-Click deploy links for 10+ of the most common cloud providers, to make things easier. Note that if your instance is exposed to the internet, it will be your responsibility to adequately secure it.
@ -236,13 +258,16 @@ yarn build
surge ./dist
```
---
### Hosting with CDN
## Hosting with CDN
Once Dashy has been built, it is effectivley just a static web app. This means that it can be served up with pretty much any static host, CDN or web server. To host Dashy through a CDN, the steps are very similar to building from source: clone the project, cd into it, install dependencies, write your config file and build the app. Once build is complete you will have a `./dist` directory within Dashy's root, and this is the build application which is ready to be served up.
However without Dashy's node server, there are a couple of features that will be unavailible to you, including: Writing config changes to disk through the UI, triggering a rebuild through the UI and application status checks. Everything else will work fine.
---
## Requirements

View File

@ -193,6 +193,7 @@ Styleguides:
├── package.json # Project meta-data, dependencies and paths to scripts
├── src/ # Project front-end source code
├── server.js # A Node.js server to serve up the /dist directory
├── services/ # All server-side endpoints and utilities
├── vue.config.js # Vue.js configuration
├── Dockerfile # The blueprint for building the Docker container
├── docker-compose.yml # A Docker run command
@ -214,6 +215,9 @@ Styleguides:
│ ├── locales # All app text, each language in a separate JSON file
│ ╰── interface-icons # SVG icons used in the app
├── components # All front-end Vue web components
│ ├── Charts # Charting components for dynamically displaying widget data
│ │ ├── Gauge.vue # A speed-dial style chart for showing 0 - 100 values
│ │ ╰── PercentageChart.vue # A horizontal bar for showing percentage breakdowns
│ ├── Configuration # Components relating to the user config pop-up
│ │ ├── AppInfoModal.vue # A modal showing core app info, like version, language, etc
│ │ ├── AppVersion.vue # Shows current version from package.json, compares with GitHub
@ -225,15 +229,30 @@ Styleguides:
│ │ ╰── RebuildApp.vue # A component allowing user to trigger a rebuild through the UI
│ ├── FormElements # Basic form elements used throughout the app
│ │ ├── Button.vue # Standard button component
│ │ ╰── Input.vue # Standard text field input component
│ │ ├── Radio.vue # Standard radio button input
│ │ ├── Select.vue # Standard dropdown input selector
│ │ ├── Input.vue # Standard text field input component
│ │ ╰── Toggle.vue # Standard on / off toggle switch
│ ├── InteractiveEditor # Components for the interactive UI config editor
│ │ ├── AddNewSectionLauncher # Button that launches the EditSection form, used for adding new section
│ │ ├── EditAppConfig.vue # Form for editing appConfig
│ │ ├── EditPageInfo.vue # Form for editing pageInfo
│ │ ├── EditSection.vue # Form for adding / editing sections
│ │ ├── EditItem.vue # Form for adding or editing items
│ │ ├── EditModeSaveMenu.vue # The bar at the bottom of screen in edit mode, containing save buttons
│ │ ├── EditModeTopBanner.vue # The bar at the top of screen in edit mode
│ │ ├── ExportConfigMenu.vue # Modal for viewing / exporting edited config
│ │ ├── MoveItemTo.vue # Form for moving / copying items to other sections
│ │ ╰── SaveCancelButtons.vue # Buttons visible in all the edit menus, to save or cancel changes
│ ├── LinkItems # Components for Sections and Link Items
│ │ ├── Collapsable.vue # The collapsible functionality of sections
│ │ ├── ContextMenu.vue # The right-click menu, for showing Item opening methods and info
│ │ ├── IframeModal.vue # Pop-up iframe modal, for viewing websites within the app
│ │ ├── Item.vue # Main link item, which is displayed within an item group
│ │ ├── ItemGroup.vue # Item group is a section containing icons
│ │ ├── ItemIcon.vue # The icon used by both items and sections
│ │ ├── ItemOpenMethodIcon.vue # A small icon, visible on hover, indicating opening method
│ │ ├── ItemContextMenu.vue # The right-click menu, for showing Item opening methods and info
│ │ ├── SectionContextMenu.vue # The right-click menu, for showing Section edit/ open options
│ │ ╰── StatusIndicator.vue # Traffic light dot, showing if app is online or down
│ ├── Minimal View # Components used for the startpage / minimal alternative view
│ │ ├── MinimalHeading.vue # Title part of minimal view
@ -250,7 +269,10 @@ Styleguides:
│ │ ├── SideBar.vue # The left sidebar for the workspace view
│ │ ├── SideBarItem.vue # App item for the sidebar view
│ │ ├── SideBarSection.vue # Collapsible collection of items within workspace sidebar
│ │ ╰── WebContent.vue # Workspace iframe view, displays content of current app
│ │ ├── WebContent.vue # Workspace iframe view, displays content of current app
│ │ ╰── WidgetView.vue # Workspace container for displaying widgets in main content
│ ├── Widgets # Directory contains all custom widget components
│ │ ╰── .... # Too many to list, see widget docs instead
│ ╰── Settings # Components relating to the quick-settings, in the top-right
│ ├── AuthButtons.vue # Logout button and other app info
│ ├── ConfigLauncher.vue # Icon that when clicked will launch the Configuration component
@ -266,6 +288,19 @@ Styleguides:
├── registerServiceWorker.js # Registers and manages service workers, for PWA apps
├── router.js # Defines all available application routes
├── styles # Directory of all globally used common SCSS styles
│ ├── color-palette.scss # All color variable names and default values
│ ├── color-themes.scss # All variable values for built-in themes
│ ├── dimensions.scss # Dimensions and sizes as variables
│ ├── global-styles.scss # Basics and style resets used globally
│ ├── media-queries.scss # Screen sizes and media queries
│ ├── style-helpers.scss # SCSS functions used for modifying values
│ ├── typography.scss # Font and text styles used globally
│ ╰── user-defined-themes.scss # Empty, put any custom styles or themes here
├── mixins # Reusable component bases, extended by other views / components
│ ├── ChartingMixin.js # Functions for rendering charts in widget components
│ ├── GlancesMixin.js # Functions for fetching system info from Glances for widgets
│ ├── HomeMixin.js # Functions for homepage, used by default, minimal and workspace views
│ ╰── WidgetMixin.js # Functions for all widgets, like data fetching, updating and error handling
├── utils # Directory of re-used helper functions
│ ├── ArrowKeyNavigation.js # Functionality for arrow-key navigation
│ ├── Auth.js # Handles all authentication related actions
@ -285,6 +320,7 @@ Styleguides:
│ ├── InitServiceWorker.js # Initializes and manages service worker, if enabled
│ ├── Search.js # Helper functions for searching/ filtering items in all views
│ ├── JsonToYaml.js # Function that parses and converts raw JSON into valid YAML
│ ├── KeycloakAuth.js # Singleton class to manage Keycloak authentication
│ ├── languages.js # Handles fetching, switching and validating languages
│ ╰── ThemeHelper.js # Function that handles the fetching and setting of user themes
╰── views # Directory of available pages, corresponding to available routes

View File

@ -1,221 +1,432 @@
# Development Guides
A series of short tutorials, to guide you through the most common development tasks.
Sections:
- [Creating a new theme](#creating-a-new-theme)
- [Writing Translations](#writing-translations)
- [Adding a new option in the config file](#adding-a-new-option-in-the-config-file)
- [Updating Dependencies](#updating-dependencies)
## Creating a new theme
Adding a new theme is really easy. There's two things you need to do: Pass the theme name to Dashy, so that it can be added to the theme selector dropdown menu, and then write some styles!
##### 1. Add Theme Name
Choose a snappy name for you're theme, and add it to the `builtInThemes` array inside [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js#L27).
##### 2. Write some Styles!
Put your theme's styles inside [`color-themes.scss`](https://github.com/Lissy93/dashy/blob/master/src/styles/color-themes.scss).
Create a new block, and make sure that `data-theme` matches the theme name you chose above. For example:
```css
html[data-theme='tiger'] {
--primary: #f58233;
--background: #0b1021;
}
```
Then you can go ahead and write you're own custom CSS. Although all CSS is supported here, the best way to define you're theme is by setting the CSS variables. You can find a [list of all CSS variables, here](https://github.com/Lissy93/dashy/blob/master/docs/theming.md#css-variables).
For a full guide on styling, see [Theming Docs](./theming.md).
Note that if you're theme is just for yourself, and you're not submitting a PR, then you can instead just pass it under `appConfig.cssThemes` inside your config file. And then put your theme in your own stylesheet, and pass it into the Docker container - [see how](https://github.com/Lissy93/dashy/blob/master/docs/theming.md#adding-your-own-theme).
## Writing Translations
For full docs about Dashy's multi-language support, see [Multi-Language Support](./multi-language-support.md)
Dashy is using [vue-i18n](https://vue-i18n.intlify.dev/guide/) to manage multi-language support.
Adding a new language is pretty straightforward, with just three steps:
##### 1. Create a new Language File
Create a new JSON file in `./src/assets/locales` name is a 2-digit [ISO-639 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language, E.g. for German `de.json`, French `fr.json` or Spanish `es.json` - You can find a list of all ISO codes at [iso.org](https://www.iso.org/obp/ui).
##### 2. Translate!
Using [`en.json`](https://github.com/Lissy93/dashy/tree/master/src/assets/locales/en.json) as an example, translate the JSON values to your language, while leaving the keys as they are. It's fine to leave out certain items, as if they're missing they will fall-back to English. If you see any attribute which include curly braces (`{xxx}`), then leave the inner value of these braces as is, as this is for variables.
```json
{
"theme-maker": {
"export-button": "Benutzerdefinierte Variablen exportieren",
"reset-button": "Stile zurücksetzen für",
"show-all-button": "Alle Variablen anzeigen",
"save-button": "Speichern",
"cancel-button": "Abbrechen",
"saved-toast": "{theme} Erfolgreich aktualisiert",
"reset-toast": "Benutzerdefinierte Farben für {theme} entfernt"
},
}
```
##### 3. Add your file to the app
In [`./src/utils/languages.js`](https://github.com/Lissy93/dashy/tree/master/src/utils/languages.js), you need to do 2 small things:
First import your new translation file, do this at the top of the page.
E.g. `import de from '@/assets/locales/de.json';`
Second, add it to the array of languages, e.g:
```javascript
export const languages = [
{
name: 'English',
code: 'en',
locale: en,
flag: '🇬🇧',
},
{
name: 'German', // The name of your language
code: 'de', // The ISO code of your language
locale: de, // The name of the file you imported (no quotes)
flag: '🇩🇪', // An optional flag emoji
},
];
```
You can also add your new language to the readme, under the [Language Switching](https://github.com/Lissy93/dashy#language-switching-) section, and optionally include your name/ username if you'd like to be credited for your work. Done!
If you are not comfortable with making pull requests, or do not want to modify the code, then feel free to instead send the translated file to me, and I can add it into the application. I will be sure to credit you appropriately.
# Adding a new option in the config file
This section is for, if you're adding a new component or setting, that requires an additional item to be added to the users config file.
All of the users config is specified in `./public/conf.yml` - see [Configuring Docs](./configuring.md) for info.
Before adding a new option in the config file, first ensure that there is nothing similar available, that is is definitely necessary, it will not conflict with any other options and most importantly that it will not cause any breaking changes. Ensure that you choose an appropriate and relevant section to place it under.
Next decide the most appropriate place for your attribute:
- Application settings should be located under `appConfig`
- Page info (such as text and metadata) should be under `pageInfo`
- Data relating to specific sections should be under `section[n].displayData`
- And for setting applied to specific items, it should be under `item[n]`
In order for the user to be able to add your new attribute using the Config Editor, and for the build validation to pass, your attribute must be included within the [ConfigSchema](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigSchema.js). You can read about how to do this on the [ajv docs](https://ajv.js.org/json-schema.html). Give your property a type and a description, as well as any other optional fields that you feel are relevant. For example:
```json
"fontAwesomeKey": {
"type": "string",
"pattern": "^[a-z0-9]{10}$",
"description": "API key for font-awesome",
"example": "0821c65656"
}
```
or
```json
"iconSize": {
"enum": [ "small", "medium", "large" ],
"default": "medium",
"description": "The size of each link item / icon"
}
```
Next, if you're property should have a default value, then add it to [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js). This ensures that nothing will break if the user does not use your property, and having all defaults together keeps things organised and easy to manage.
If your property needs additional logic for fetching, setting or processing, then you can add a helper function within [`ConfigHelpers.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigHelpers.js).
Finally, add your new property to the [`configuring.md`](./configuring.md) API docs. Put it under the relevant section, and be sure to include field name, data type, a description and mention that it is optional. If your new feature needs more explaining, then you can also document it under the relevant section elsewhere in the documentation.
Checklist:
- [ ] Ensure the new attribute is actually necessary, and nothing similar already exists
- [ ] Update the [Schema](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigSchema.js) with the parameters for your new option
- [ ] Set a default value (if required) within [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js)
- [ ] Document the new value in [`configuring.md`](./configuring.md)
- [ ] Test that the reading of the new attribute is properly handled, and will not cause any errors when it is missing or populated with an unexpected value
---
## Updating Dependencies
Running `yarn upgrade` will updated all dependencies based on the ranges specified in the `package.json`. The `yarn.lock` file will be updated, as will the contents of `./node_modules`, for more info, see the [yarn upgrade documentation](https://classic.yarnpkg.com/en/docs/cli/upgrade/). It is important to thoroughly test after any big dependency updates.
---
## Developing Netlify Cloud Functions
When Dashy is deployed to Netlify, it is effectively running as a static app, and therefore the server-side code for the Node.js endpoints is not available. However Netlify now supports serverless cloud lambda functions, which can be used to replace most functionality.
#### 1. Run Netlify Dev Server
First off, install the Netlify CLI: `npm install netlify-cli -g`
Then, from within the root of Dashy's directory, start the server, by running: `netlify dev`
#### 2. Create a lambda function
This should be saved it in the [`./services/serverless-functions`](https://github.com/Lissy93/dashy/tree/master/services/serverless-functions) directory
```javascript
exports.handler = async () => ({
statusCode: 200,
body: 'Return some data here...',
});
```
#### 3. Redirect the Node endpoint to the function
In the [`netlify.toml`](https://github.com/Lissy93/dashy/blob/FEATURE/serverless-functions/netlify.toml) file, add a 301 redirect, with the path to the original Node.js endpoint, and the name of your cloud function
```toml
[[redirects]]
from = "/status-check"
to = "/.netlify/functions/cloud-status-check"
status = 301
force = true
```
---
## Hiding Page Furniture on Certain Routes
For some pages (such as the login page, the minimal start page, etc) the basic page furniture, (like header, footer, nav, etc) is not needed. This section explains how you can hide furniture on a new view (step 1), or add a component that should be hidden on certain views (step 2).
##### 1. Add the route name to the should hide array
In [`./src/utils/defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js), there's an array called `hideFurnitureOn`. Append the name of the route (the same as it appears in [`router.js`](https://github.com/Lissy93/dashy/blob/master/src/router.js)) here.
##### 2. Add the conditional to the structural component to hide
First, import the helper function:
```javascript
import { shouldBeVisible } from '@/utils/MiscHelpers';
```
Then you can create a computed value, that calls this function, passing in the route name:
```javascript
export default {
...
computed: {
...
isVisible() {
return shouldBeVisible(this.$route.name);
},
},
};
```
Finally, in the markup of your component, just add a `v-if` statement, referencing your computed value
```vue
<header v-if="isVisible">
...
</header>
```
---
## Adding / Using Environmental Variables
All environmental variables are optional. Currently there are not many environmental variables used, as most of the user preferences are stored under `appConfig` in the `conf.yml` file.
You can set variables either in your environment, or using the [`.env`](https://github.com/Lissy93/dashy/blob/master/.env) file.
Any environmental variables used by the frontend are preceded with `VUE_APP_`. Vue will merge the contents of your `.env` file into the app in a similar way to the ['dotenv'](https://github.com/motdotla/dotenv) package, where any variables that you set on your system will always take preference over the contents of any `.env` file.
If add any new variables, ensure that there is always a fallback (define it in [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js)), so as to not cause breaking changes. Don't commit the contents of your `.env` file to git, but instead take a few moments to document what you've added under the appropriate section. Try and follow the concepts outlined in the [12 factor app](https://12factor.net/config).
# Development Guides
A series of short tutorials, to guide you through the most common development tasks.
Sections:
- [Creating a new theme](#creating-a-new-theme)
- [Writing Translations](#writing-translations)
- [Adding a new option in the config file](#adding-a-new-option-in-the-config-file)
- [Updating Dependencies](#updating-dependencies)
- [Writing Netlify Cloud Functions](#developing-netlify-cloud-functions)
- [Hiding Page Furniture](#hiding-page-furniture-on-certain-routes)
- [Adding / Using Environmental Variables](#adding--using-environmental-variables)
- [Building a Widget](#building-a-widget)
## Creating a new theme
Adding a new theme is really easy. There's two things you need to do: Pass the theme name to Dashy, so that it can be added to the theme selector dropdown menu, and then write some styles!
##### 1. Add Theme Name
Choose a snappy name for you're theme, and add it to the `builtInThemes` array inside [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js#L27).
##### 2. Write some Styles!
Put your theme's styles inside [`color-themes.scss`](https://github.com/Lissy93/dashy/blob/master/src/styles/color-themes.scss).
Create a new block, and make sure that `data-theme` matches the theme name you chose above. For example:
```css
html[data-theme='tiger'] {
--primary: #f58233;
--background: #0b1021;
}
```
Then you can go ahead and write you're own custom CSS. Although all CSS is supported here, the best way to define you're theme is by setting the CSS variables. You can find a [list of all CSS variables, here](https://github.com/Lissy93/dashy/blob/master/docs/theming.md#css-variables).
For a full guide on styling, see [Theming Docs](./theming.md).
Note that if you're theme is just for yourself, and you're not submitting a PR, then you can instead just pass it under `appConfig.cssThemes` inside your config file. And then put your theme in your own stylesheet, and pass it into the Docker container - [see how](https://github.com/Lissy93/dashy/blob/master/docs/theming.md#adding-your-own-theme).
## Writing Translations
For full docs about Dashy's multi-language support, see [Multi-Language Support](./multi-language-support.md)
Dashy is using [vue-i18n](https://vue-i18n.intlify.dev/guide/) to manage multi-language support.
Adding a new language is pretty straightforward, with just three steps:
##### 1. Create a new Language File
Create a new JSON file in `./src/assets/locales` name is a 2-digit [ISO-639 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language, E.g. for German `de.json`, French `fr.json` or Spanish `es.json` - You can find a list of all ISO codes at [iso.org](https://www.iso.org/obp/ui).
##### 2. Translate!
Using [`en.json`](https://github.com/Lissy93/dashy/tree/master/src/assets/locales/en.json) as an example, translate the JSON values to your language, while leaving the keys as they are. It's fine to leave out certain items, as if they're missing they will fall-back to English. If you see any attribute which include curly braces (`{xxx}`), then leave the inner value of these braces as is, as this is for variables.
```json
{
"theme-maker": {
"export-button": "Benutzerdefinierte Variablen exportieren",
"reset-button": "Stile zurücksetzen für",
"show-all-button": "Alle Variablen anzeigen",
"save-button": "Speichern",
"cancel-button": "Abbrechen",
"saved-toast": "{theme} Erfolgreich aktualisiert",
"reset-toast": "Benutzerdefinierte Farben für {theme} entfernt"
},
}
```
##### 3. Add your file to the app
In [`./src/utils/languages.js`](https://github.com/Lissy93/dashy/tree/master/src/utils/languages.js), you need to do 2 small things:
First import your new translation file, do this at the top of the page.
E.g. `import de from '@/assets/locales/de.json';`
Second, add it to the array of languages, e.g:
```javascript
export const languages = [
{
name: 'English',
code: 'en',
locale: en,
flag: '🇬🇧',
},
{
name: 'German', // The name of your language
code: 'de', // The ISO code of your language
locale: de, // The name of the file you imported (no quotes)
flag: '🇩🇪', // An optional flag emoji
},
];
```
You can also add your new language to the readme, under the [Language Switching](https://github.com/Lissy93/dashy#language-switching-) section, and optionally include your name/ username if you'd like to be credited for your work. Done!
If you are not comfortable with making pull requests, or do not want to modify the code, then feel free to instead send the translated file to me, and I can add it into the application. I will be sure to credit you appropriately.
# Adding a new option in the config file
This section is for, if you're adding a new component or setting, that requires an additional item to be added to the users config file.
All of the users config is specified in `./public/conf.yml` - see [Configuring Docs](./configuring.md) for info.
Before adding a new option in the config file, first ensure that there is nothing similar available, that is is definitely necessary, it will not conflict with any other options and most importantly that it will not cause any breaking changes. Ensure that you choose an appropriate and relevant section to place it under.
Next decide the most appropriate place for your attribute:
- Application settings should be located under `appConfig`
- Page info (such as text and metadata) should be under `pageInfo`
- Data relating to specific sections should be under `section[n].displayData`
- And for setting applied to specific items, it should be under `item[n]`
In order for the user to be able to add your new attribute using the Config Editor, and for the build validation to pass, your attribute must be included within the [ConfigSchema](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigSchema.js). You can read about how to do this on the [ajv docs](https://ajv.js.org/json-schema.html). Give your property a type and a description, as well as any other optional fields that you feel are relevant. For example:
```json
"fontAwesomeKey": {
"type": "string",
"pattern": "^[a-z0-9]{10}$",
"description": "API key for font-awesome",
"example": "0821c65656"
}
```
or
```json
"iconSize": {
"enum": [ "small", "medium", "large" ],
"default": "medium",
"description": "The size of each link item / icon"
}
```
Next, if you're property should have a default value, then add it to [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js). This ensures that nothing will break if the user does not use your property, and having all defaults together keeps things organised and easy to manage.
If your property needs additional logic for fetching, setting or processing, then you can add a helper function within [`ConfigHelpers.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigHelpers.js).
Finally, add your new property to the [`configuring.md`](./configuring.md) API docs. Put it under the relevant section, and be sure to include field name, data type, a description and mention that it is optional. If your new feature needs more explaining, then you can also document it under the relevant section elsewhere in the documentation.
Checklist:
- [ ] Ensure the new attribute is actually necessary, and nothing similar already exists
- [ ] Update the [Schema](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigSchema.js) with the parameters for your new option
- [ ] Set a default value (if required) within [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js)
- [ ] Document the new value in [`configuring.md`](./configuring.md)
- [ ] Test that the reading of the new attribute is properly handled, and will not cause any errors when it is missing or populated with an unexpected value
---
## Updating Dependencies
Running `yarn upgrade` will updated all dependencies based on the ranges specified in the `package.json`. The `yarn.lock` file will be updated, as will the contents of `./node_modules`, for more info, see the [yarn upgrade documentation](https://classic.yarnpkg.com/en/docs/cli/upgrade/). It is important to thoroughly test after any big dependency updates.
---
## Developing Netlify Cloud Functions
When Dashy is deployed to Netlify, it is effectively running as a static app, and therefore the server-side code for the Node.js endpoints is not available. However Netlify now supports serverless cloud lambda functions, which can be used to replace most functionality.
#### 1. Run Netlify Dev Server
First off, install the Netlify CLI: `npm install netlify-cli -g`
Then, from within the root of Dashy's directory, start the server, by running: `netlify dev`
#### 2. Create a lambda function
This should be saved it in the [`./services/serverless-functions`](https://github.com/Lissy93/dashy/tree/master/services/serverless-functions) directory
```javascript
exports.handler = async () => ({
statusCode: 200,
body: 'Return some data here...',
});
```
#### 3. Redirect the Node endpoint to the function
In the [`netlify.toml`](https://github.com/Lissy93/dashy/blob/FEATURE/serverless-functions/netlify.toml) file, add a 301 redirect, with the path to the original Node.js endpoint, and the name of your cloud function
```toml
[[redirects]]
from = "/status-check"
to = "/.netlify/functions/cloud-status-check"
status = 301
force = true
```
---
## Hiding Page Furniture on Certain Routes
For some pages (such as the login page, the minimal start page, etc) the basic page furniture, (like header, footer, nav, etc) is not needed. This section explains how you can hide furniture on a new view (step 1), or add a component that should be hidden on certain views (step 2).
##### 1. Add the route name to the should hide array
In [`./src/utils/defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js), there's an array called `hideFurnitureOn`. Append the name of the route (the same as it appears in [`router.js`](https://github.com/Lissy93/dashy/blob/master/src/router.js)) here.
##### 2. Add the conditional to the structural component to hide
First, import the helper function:
```javascript
import { shouldBeVisible } from '@/utils/SectionHelpers';
```
Then you can create a computed value, that calls this function, passing in the route name:
```javascript
export default {
...
computed: {
...
isVisible() {
return shouldBeVisible(this.$route.name);
},
},
};
```
Finally, in the markup of your component, just add a `v-if` statement, referencing your computed value
```vue
<header v-if="isVisible">
...
</header>
```
---
## Adding / Using Environmental Variables
All environmental variables are optional. Currently there are not many environmental variables used, as most of the user preferences are stored under `appConfig` in the `conf.yml` file.
You can set variables either in your environment, or using the [`.env`](https://github.com/Lissy93/dashy/blob/master/.env) file.
Any environmental variables used by the frontend are preceded with `VUE_APP_`. Vue will merge the contents of your `.env` file into the app in a similar way to the ['dotenv'](https://github.com/motdotla/dotenv) package, where any variables that you set on your system will always take preference over the contents of any `.env` file.
If add any new variables, ensure that there is always a fallback (define it in [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js)), so as to not cause breaking changes. Don't commit the contents of your `.env` file to git, but instead take a few moments to document what you've added under the appropriate section. Try and follow the concepts outlined in the [12 factor app](https://12factor.net/config).
---
## Building a Widget
### Step 0 - Prerequisites
If this is your first time working on Dashy, then the [Developing Docs](https://github.com/Lissy93/dashy/blob/master/docs/developing.md) instructions for project setup and running. In short, you just need to clone the project, cd into it, install dependencies (`yarn`) and then start the development server (`yarn dev`).
To build a widget, you'll also need some basic knowledge of Vue.js. The [official Vue docs](https://vuejs.org/v2/guide/) provides a good starting point, as does [this guide](https://www.taniarascia.com/getting-started-with-vue/) by Tania Rascia
If you just want to jump straight in, then [here](https://github.com/Lissy93/dashy/commit/3da76ce2999f57f76a97454c0276301e39957b8e) is a complete implementation of a new example widget, or take a look at the [`XkcdComic.vue`](https://github.com/Lissy93/dashy/blob/master/src/components/Widgets/XkcdComic.vue) widget, which is pretty simple.
### Step 1 - Create Widget
Firstly, create a new `.vue` file under [`./src/components/Widgets`](https://github.com/Lissy93/dashy/tree/master/src/components/Widgets).
```vue
<template>
<div class="example-wrapper">
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
data() {
return {
results: null,
};
},
computed: {
endpoint() {
return `${widgetApiEndpoints.myApi}/something`;
},
},
methods: {
fetchData() {
this.makeRequest(this.endpoint).then(this.processData);
},
processData(data) {
// Do processing any here, and set component data
this.results = data;
},
},
};
</script>
<style scoped lang="scss">
</style>
```
All widgets extend from the [Widget](https://github.com/Lissy93/dashy/blob/master/src/mixins/WidgetMixin.js) mixin. This provides some basic functionality that is shared by all widgets. The mixin includes the following `options`, `startLoading()`, `finishLoading()`, `error()` and `update()`.
- **Getting user options: `options`**
- Any user-specific config can be accessed with `this.options.something` (where something is the data key your accessing)
- **Loading state: `startLoading()` and `finishLoading()`**
- You can show the loader with `this.startLoading()`, then when your data request completes, hide it again with `this.finishLoading()`
- **Error handling: `error()`**
- If something goes wrong (such as API error, or missing user parameters), then call `this.error()` to show message to user
- **Updating data: `update()`**
- When the user clicks the update button, or if continuous updates are enabled, then the `update()` method within your widget will be called
### Step 2 - Adding Functionality
**Accessing User Options**
If your widget is going to accept any parameters from the user, then we can access these with `this.options.[parmName]`. It's best to put these as computed properties, which will enable us to check it exists, is valid, and if needed format it. For example, if we have an optional property called `count` (to determine number of results), we can do the following, and then reference it within our component with `this.count`
```javascript
computed: {
count() {
if (!this.options.count) {
return 5;
}
return this.options.count;
},
...
},
```
**Adding an API Endpoint**
If your widget makes a data request, then add the URL for the API under point to the `widgetApiEndpoints` array in [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js#L207)
```javascript
widgetApiEndpoints: {
...
exampleEndpoint: 'https://hub.dummyapis.com/ImagesList',
},
```
Then in your widget file:
```javascript
import { widgetApiEndpoints } from '@/utils/defaults';
```
For GET requests, you may need to add some parameters onto the end of the URL. We can use another computed property for this, for example:
```javascript
endpoint() {
return `${widgetApiEndpoints.exampleEndpoint}?count=${this.count}`;
},
```
**Making an API Request**
Axios is used for making data requests, so import it into your component: `import axios from 'axios';`
Under the `methods` block, we'll create a function called `fetchData`, here we can use Axios to make a call to our endpoint.
```javascript
fetchData() {
this.makeRequest(this.endpoint, this.headers).then(this.processData);
},
```
There are three things happening here:
- If the response completes successfully, we'll pass the results to another function that will handle them
- If there's an error, then we call `this.error()`, which will show a message to the user
- Whatever the result, once the request has completed, we call `this.finishLoading()`, which will hide the loader
**Processing Response**
In the above example, we call the `processData()` method with the result from the API, so we need to create that under the `methods` section. How you handle this data will vary depending on what's returned by the API, and what you want to render to the user. But however you do it, you will likely need to create a data variable to store the response, so that it can be easily displayed in the HTML.
```javascript
data() {
return {
myResults: null,
};
},
```
And then, inside your `processData()` method, you can set the value of this, with:
```javascript
`this.myResults = 'whatever'`
```
**Rendering Response**
Now that the results are in the correct format, and stored as data variables, we can use them within the `<template>` to render results to the user. Again, how you do this will depend on the structure of your data, and what you want to display, but at it's simplest, it might look something like this:
```vue
<p class="results">{{ myResults }}</p>
```
**Styling**
Styles can be written your your widget within the `<style>` block.
There are several color variables used by widgets, which extend from the base pallete. Using these enables users to override colors to theme their dashboard, if they wish. The variables are: `--widget-text-color`, `--widget-background-color` and `--widget-accent-color`
```vue
<style scoped lang="scss">
p.results {
color: var(--widget-text-color);
}
</style>
```
For examples of finished widget components, see the [Widgets](https://github.com/Lissy93/dashy/tree/master/src/components/Widgets) directory. Specifically, the [`XkcdComic.vue`](https://github.com/Lissy93/dashy/blob/master/src/components/Widgets/XkcdComic.vue) widget is quite minimal, so would make a good example, as will [this example implementation](https://github.com/Lissy93/dashy/commit/3da76ce2999f57f76a97454c0276301e39957b8e).
### Step 3 - Register
Next, import and register your new widget, in [`WidgetBase.vue`](https://github.com/Lissy93/dashy/blob/master/src/components/Widgets/WidgetBase.vue). In this file, you'll need to add the following:
Import your widget file
```javascript
import ExampleWidget from '@/components/Widgets/ExampleWidget.vue';
```
Then register the component
```javascript
components: {
...
ExampleWidget,
},
```
Finally, add the markup to render it. The only attribute you need to change here is, setting `widgetType === 'example'` to your widget's name.
```vue
<ExampleWidget
v-else-if="widgetType === 'example'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
```
### Step 4 - Docs
Finally, add some documentation for your widget in the [Widget Docs](https://github.com/Lissy93/dashy/blob/master/docs/widgets.md), so that others know hoe to use it. Include the following information: Title, short description, screenshot, config options and some example YAML.
**Summary**: For a complete example of everything discussed here, see: [`3da76ce`](https://github.com/Lissy93/dashy/commit/3da76ce2999f57f76a97454c0276301e39957b8e)

View File

@ -1,190 +1,191 @@
# Icons
Both sections and items can have an icon, which is specified using the `icon` attribute. Using icons improves the aesthetics of your UI and makes the app more intuitive to use. Dashy supports multiple different icon providers, usage instructions for which are explained here.
- [Auto-Fetched Favicons](#favicons)
- [Font Awesome Icons](#font-awesome)
- [Simple Icons](#simple-icons)
- [Generative Icons](#generative-icons)
- [Emoji Icons](#emoji-icons)
- [Home-Lab Icons](#home-lab-icons)
- [Material Icons](#material-design-icons)
- [Icons by URL](#icons-by-url)
- [Local Icons](#local-icons)
- [No Icon](#no-icon)
<p align="center">
<img width="500" src="https://i.ibb.co/GTVmZnc/dashy-example-icons.png" />
</p>
---
## Favicons
Dashy can auto-fetch an icon for a given service, using it's favicon. Just set `icon: favicon` to use this feature.
<p align="center">
<img width="580" src="https://i.ibb.co/k6wyhnB/favicon-icons.png" />
</p>
Since different websites host their favicons at different paths, for the best results Dashy can use an API to resolve a websites icon.
The default favicon API is [allesedv.com](https://favicon.allesedv.com/), but you can change this under `appConfig.faviconApi`. If you'd prefer not to use an API, just set this value to `local`. You can also use different APIs for individual items, by setting `icon: favicon-[api]`, e.g. `favicon-clearbit`.
The following favicon APIs are supported:
- `allesedv` - [allesedv.com](https://favicon.allesedv.com/) is a highly efficient IPv6-enabled service
- `clearbit` - [Clearbit](https://clearbit.com/logo) returns high-quality square logos from mainstream websites
- `faviconkit` - [faviconkit.com](https://faviconkit.com/) good quality icons and most sites supported (Note: down as of Nov '21)
- `besticon` - [BestIcon](https://github.com/mat/besticon) fetches websites icons from manifest
- `mcapi` - [MC-API](https://eu.mc-api.net/) fetches default website favicon, originally a Minecraft util
- `duckduckgo` - Returns decent quality website icons, from DuckDuckGo search
- `google` - Official Google favicon API service, good support for all sites, but poor quality
- `yandex` - Lower quality icons, but useful in some regions where other services are blocked
- `local` - Set to local to fetch the default icon at /favicon.ico instead of using an API
If for a given service none of the APIs work in your situation, and nor does local, then the best option is to find the path of the services logo or favicon, and set the icon to the URL of the raw image. For example, `icon: https://monitoring.local/faviconx128.png`- you can find this path using the browser dev tools.
---
## Font Awesome
You can use any [Font Awesome Icon](https://fontawesome.com/icons) simply by specifying it's identifier. This is in the format of `[category] [name]` and can be found on the page for that icon on the Font Awesome site. For example: `fas fa-rocket`, `fab fa-monero` or `fas fa-unicorn`.
Font-Awesome has a wide variety of free icons, but you can also use their pro icons if you have a membership. To do so, you need to specify your license key under: `appConfig.fontAwesomeKey`. This is usually a 10-digit string, for example `13014ae648`.
<p align="center">
<img width="500" src="https://i.ibb.co/tMtwNYZ/fontawesome-icons3.png" />
</p>
---
## Simple Icons
[SimpleIcons.org](https://simpleicons.org/) is a collection of 2000+ high quality, free and open source brand and logo SVG icons. Usage of which is very similar to font-awesome icons. First find the glyph you want to use on the [website](https://simpleicons.org/), then just set your icon the the simple icon slug, prefixed with `si-`.
<p align="center">
<img width="580" src="https://i.ibb.co/MVhkXfC/simple-icons-example.png" />
</p>
For example:
```yaml
sections:
- name: Simple Icons Example
items:
- title: Portainer
icon: si-portainer
- title: FreeNAS
icon: si-freenas
- title: NextCloud
icon: si-nextcloud
- title: Home Assistant
icon: si-homeassistant
```
---
## Generative Icons
To uses a unique and programmatically generated icon for a given service just set `icon: generative`. This is particularly useful when you have a lot of similar services with a different IP or port, and no specific icon. These icons are generated with [DiceBear](https://avatars.dicebear.com/) (or [Evatar](https://evatar.io/) for fallback), and use a hash of the services domain/ ip for entropy, so each domain will have a unique icon.
<p align="center">
<img width="500" src="https://i.ibb.co/b2pC2CL/generative-icons-2.png" />
</p>
---
## Emoji Icons
You can use almost any emoji as an icon for items or sections. You can specify the emoji either by pasting it directly, using it's unicode ( e.g. `'U+1F680'`) or shortcode (e.g. `':rocket:'`). You can find these codes for any emoji using [Emojipedia](https://emojipedia.org/) (near the bottom of emoji each page), or for a quick reference to emoji shortcodes, check out [emojis.ninja](https://emojis.ninja/) by @nomanoff.
<p align="center">
<img width="580" src="https://i.ibb.co/YLwgTf9/emoji-icons-1.png" />
</p>
For example, these will all render the same rocket (🚀) emoji: `icon: ':rocket:'` or `icon: 'U+1F680'` or `icon: 🚀`
---
## Home-Lab Icons
The [dashboard-icons](https://github.com/WalkxCode/dashboard-icons) repo by [@WalkxCode](https://github.com/WalkxCode) provides a comprehensive collection of 360+ high-quality PNG icons for commonly self-hosted services. Dashy natively supports these icons, and you can use them just by specifying the icon name (without extension) preceded by `hl-`. See [here](https://github.com/WalkxCode/dashboard-icons/tree/master/png) for a full list of all available icons. Note that these are fetched and cached strait from GitHub, so if you require offline access, the [Local Icons](#local-icons) method may be a better option for you.
For example:
```yaml
sections:
- name: Home Lab Icons Example
items:
- title: AdGuard Home
icon: hl-adguardhome
- title: Long Horn
icon: hl-longhorn
- title: Nagios
icon: hl-nagios
- title: Whoogle Search
icon: hl-whooglesearch
```
<p align="center">
<img width="580" src="https://i.ibb.co/PQzYHmD/homelab-icons-2.png" />
</p>
---
## Material Design Icons
Dashy also supports 5000+ [material-design-icons](https://github.com/Templarian/MaterialDesign). To use these, first find the name/ slug for your icon [here](https://dev.materialdesignicons.com/icons), and then prefix is with `mdi-`.
For example:
```yaml
sections:
- name: Material Design Icons Example
items:
- title: Alien Icon
icon: mdi-alien
- title: Fire Icon
icon: mdi-fire
- title: Dino Icon
icon: mdi-google-downasaur
```
<p align="center">
<img width="500" src="https://i.ibb.co/fC9B4mq/icons-mdi-example.png" />
</p>
---
## Icons by URL
You can also set an icon by passing in a valid URL pointing to the icons location. For example `icon: https://i.ibb.co/710B3Yc/space-invader-x256.png`, this can be in .png, .jpg or .svg format, and hosted anywhere (local or remote) - so long as it's accessible from where you are hosting Dashy. The icon will be automatically scaled to fit, however loading in a lot of large icons may have a negative impact on performance, especially if you visit Dashy from new devices often.
---
## Local Icons
You may also want to store your icons locally, bundled within Dashy so that there is no reliance on outside services. This can be done by putting the icons within Dashy's `./public/item-icons/` directory. If you are using Docker, then the easiest option is to map a volume from your host system, for example: `-v /local/image/directory:/app/public/item-icons/`. To reference an icon stored locally, just specify it's name and extension. For example, if my icon was stored in `/app/public/item-icons/maltrail.png`, then I would just set `icon: maltrail.png`.
You can also use sub-folders within the `item-icons` directory to keep things organized. You would then specify an icon with it's folder name slash image name. For example: `networking/monit.png`
---
## No Icon
If you don't wish for a given item or section to have an icon, just leave out the `icon` attribute.
---
## Icon Collections and Resources
The following websites provide good-quality, free icon sets. To use any of these icons, either copy the link to the raw icon (it should end in `.svg` or `.png`) and paste it as your `icon`, or download and save the icons in `/public/item-icons` / mapped Docker volume. Full credit to the authors, please see the licenses for each service for usage and copyright information.
- [Icons for Self-Hosted Apps](https://thehomelab.wiki/books/helpful-tools-resources/page/icons-for-self-hosted-dashboards) - 350+ high-quality icons for commonly self-hosted services
- [SVG Box](https://svgbox.net/iconsets/) - Cryptocurrency, social media apps and flag icons
- [Simple Icons](https://simpleicons.org/) - Free SVG brand icons, with easy API access
- [Material Design Icons](https://github.com/google/material-design-icons/) - Hundreds of Open source PNG + SVG icons by Google
- [Icons8](https://icons8.com/icons) - Thousands of icons, all with free versions at 64x64
- [Flat Icon](https://www.flaticon.com/) - Wide variety of icon sets, most of which are free to use
- [SVG Repo](https://www.svgrepo.com/) - 300,000+ Vector Icons
If you are a student, then you can get free access to premium icons on [Icon Scout](https://education.github.com/pack/redeem/iconscout-student) or [Icons8](https://icons8.com/github-students) using the [GitHub Student Pack](https://education.github.com/pack).
---
## Notes
If you are using icons from an external source, these will be fetched on initial page load automatically, if and when needed. But combining icons from multiple services may have a negative impact on performance.
You can improve load speeds, by downloading your required icons, and serving them locally. Scaling icons to the minimum required dimensions (e.g. 128x128 or 64x64) will also greatly improve application load times.
For icons from external sources, please see the Privacy Policies and Licenses for that provider.
# Icons
Both sections and items can have an icon, which is specified using the `icon` attribute. Using icons improves the aesthetics of your UI and makes the app more intuitive to use. Dashy supports multiple different icon providers, usage instructions for which are explained here.
- [Auto-Fetched Favicons](#favicons)
- [Font Awesome Icons](#font-awesome)
- [Simple Icons](#simple-icons)
- [Generative Icons](#generative-icons)
- [Emoji Icons](#emoji-icons)
- [Home-Lab Icons](#home-lab-icons)
- [Material Icons](#material-design-icons)
- [Icons by URL](#icons-by-url)
- [Local Icons](#local-icons)
- [No Icon](#no-icon)
<p align="center">
<img width="500" src="https://i.ibb.co/GTVmZnc/dashy-example-icons.png" />
</p>
---
## Favicons
Dashy can auto-fetch an icon for a given service, using it's favicon. Just set `icon: favicon` to use this feature.
<p align="center">
<img width="580" src="https://i.ibb.co/k6wyhnB/favicon-icons.png" />
</p>
Since different websites host their favicons at different paths, for the best results Dashy can use an API to resolve a websites icon.
The default favicon API is [allesedv.com](https://favicon.allesedv.com/), but you can change this under `appConfig.faviconApi`. If you'd prefer not to use an API, just set this value to `local`. You can also use different APIs for individual items, by setting `icon: favicon-[api]`, e.g. `favicon-clearbit`.
The following favicon APIs are supported:
- `allesedv` - [allesedv.com](https://favicon.allesedv.com/) is a highly efficient IPv6-enabled service
- `iconhorse` - [Icon.Horse](https://icon.horse/) returns quality icons for any site, with caching for speed and fallbacks for sites without an icon
- `clearbit` - [Clearbit](https://clearbit.com/logo) returns high-quality square logos from mainstream websites
- `faviconkit` - [faviconkit.com](https://faviconkit.com/) good quality icons and most sites supported (Note: down as of Nov '21)
- `besticon` - [BestIcon](https://github.com/mat/besticon) fetches websites icons from manifest
- `mcapi` - [MC-API](https://eu.mc-api.net/) fetches default website favicon, originally a Minecraft util
- `duckduckgo` - Returns decent quality website icons, from DuckDuckGo search
- `google` - Official Google favicon API service, good support for all sites, but poor quality
- `yandex` - Lower quality icons, but useful in some regions where other services are blocked
- `local` - Set to local to fetch the default icon at /favicon.ico instead of using an API
If for a given service none of the APIs work in your situation, and nor does local, then the best option is to find the path of the services logo or favicon, and set the icon to the URL of the raw image. For example, `icon: https://monitoring.local/faviconx128.png`- you can find this path using the browser dev tools.
---
## Font Awesome
You can use any [Font Awesome Icon](https://fontawesome.com/icons) simply by specifying it's identifier. This is in the format of `[category] [name]` and can be found on the page for that icon on the Font Awesome site. For example: `fas fa-rocket`, `fab fa-monero` or `fas fa-unicorn`.
Font-Awesome has a wide variety of free icons, but you can also use their pro icons if you have a membership. To do so, you need to specify your license key under: `appConfig.fontAwesomeKey`. This is usually a 10-digit string, for example `13014ae648`.
<p align="center">
<img width="500" src="https://i.ibb.co/tMtwNYZ/fontawesome-icons3.png" />
</p>
---
## Simple Icons
[SimpleIcons.org](https://simpleicons.org/) is a collection of 2000+ high quality, free and open source brand and logo SVG icons. Usage of which is very similar to font-awesome icons. First find the glyph you want to use on the [website](https://simpleicons.org/), then just set your icon the the simple icon slug, prefixed with `si-`.
<p align="center">
<img width="580" src="https://i.ibb.co/MVhkXfC/simple-icons-example.png" />
</p>
For example:
```yaml
sections:
- name: Simple Icons Example
items:
- title: Portainer
icon: si-portainer
- title: FreeNAS
icon: si-freenas
- title: NextCloud
icon: si-nextcloud
- title: Home Assistant
icon: si-homeassistant
```
---
## Generative Icons
To uses a unique and programmatically generated icon for a given service just set `icon: generative`. This is particularly useful when you have a lot of similar services with a different IP or port, and no specific icon. These icons are generated with [DiceBear](https://avatars.dicebear.com/) (or [Evatar](https://evatar.io/) for fallback), and use a hash of the services domain/ ip for entropy, so each domain will have a unique icon.
<p align="center">
<img width="500" src="https://i.ibb.co/b2pC2CL/generative-icons-2.png" />
</p>
---
## Emoji Icons
You can use almost any emoji as an icon for items or sections. You can specify the emoji either by pasting it directly, using it's unicode ( e.g. `'U+1F680'`) or shortcode (e.g. `':rocket:'`). You can find these codes for any emoji using [Emojipedia](https://emojipedia.org/) (near the bottom of emoji each page), or for a quick reference to emoji shortcodes, check out [emojis.ninja](https://emojis.ninja/) by @nomanoff.
<p align="center">
<img width="580" src="https://i.ibb.co/YLwgTf9/emoji-icons-1.png" />
</p>
For example, these will all render the same rocket (🚀) emoji: `icon: ':rocket:'` or `icon: 'U+1F680'` or `icon: 🚀`
---
## Home-Lab Icons
The [dashboard-icons](https://github.com/WalkxCode/dashboard-icons) repo by [@WalkxCode](https://github.com/WalkxCode) provides a comprehensive collection of 360+ high-quality PNG icons for commonly self-hosted services. Dashy natively supports these icons, and you can use them just by specifying the icon name (without extension) preceded by `hl-`. See [here](https://github.com/WalkxCode/dashboard-icons/tree/master/png) for a full list of all available icons. Note that these are fetched and cached strait from GitHub, so if you require offline access, the [Local Icons](#local-icons) method may be a better option for you.
For example:
```yaml
sections:
- name: Home Lab Icons Example
items:
- title: AdGuard Home
icon: hl-adguardhome
- title: Long Horn
icon: hl-longhorn
- title: Nagios
icon: hl-nagios
- title: Whoogle Search
icon: hl-whooglesearch
```
<p align="center">
<img width="580" src="https://i.ibb.co/PQzYHmD/homelab-icons-2.png" />
</p>
---
## Material Design Icons
Dashy also supports 5000+ [material-design-icons](https://github.com/Templarian/MaterialDesign). To use these, first find the name/ slug for your icon [here](https://dev.materialdesignicons.com/icons), and then prefix is with `mdi-`.
For example:
```yaml
sections:
- name: Material Design Icons Example
items:
- title: Alien Icon
icon: mdi-alien
- title: Fire Icon
icon: mdi-fire
- title: Dino Icon
icon: mdi-google-downasaur
```
<p align="center">
<img width="500" src="https://i.ibb.co/fC9B4mq/icons-mdi-example.png" />
</p>
---
## Icons by URL
You can also set an icon by passing in a valid URL pointing to the icons location. For example `icon: https://i.ibb.co/710B3Yc/space-invader-x256.png`, this can be in .png, .jpg or .svg format, and hosted anywhere (local or remote) - so long as it's accessible from where you are hosting Dashy. The icon will be automatically scaled to fit, however loading in a lot of large icons may have a negative impact on performance, especially if you visit Dashy from new devices often.
---
## Local Icons
You may also want to store your icons locally, bundled within Dashy so that there is no reliance on outside services. This can be done by putting the icons within Dashy's `./public/item-icons/` directory. If you are using Docker, then the easiest option is to map a volume from your host system, for example: `-v /local/image/directory:/app/public/item-icons/`. To reference an icon stored locally, just specify it's name and extension. For example, if my icon was stored in `/app/public/item-icons/maltrail.png`, then I would just set `icon: maltrail.png`.
You can also use sub-folders within the `item-icons` directory to keep things organized. You would then specify an icon with it's folder name slash image name. For example: `networking/monit.png`
---
## No Icon
If you don't wish for a given item or section to have an icon, just leave out the `icon` attribute.
---
## Icon Collections and Resources
The following websites provide good-quality, free icon sets. To use any of these icons, either copy the link to the raw icon (it should end in `.svg` or `.png`) and paste it as your `icon`, or download and save the icons in `/public/item-icons` / mapped Docker volume. Full credit to the authors, please see the licenses for each service for usage and copyright information.
- [Icons for Self-Hosted Apps](https://thehomelab.wiki/books/helpful-tools-resources/page/icons-for-self-hosted-dashboards) - 350+ high-quality icons for commonly self-hosted services
- [SVG Box](https://svgbox.net/iconsets/) - Cryptocurrency, social media apps and flag icons
- [Simple Icons](https://simpleicons.org/) - Free SVG brand icons, with easy API access
- [Material Design Icons](https://github.com/google/material-design-icons/) - Hundreds of Open source PNG + SVG icons by Google
- [Icons8](https://icons8.com/icons) - Thousands of icons, all with free versions at 64x64
- [Flat Icon](https://www.flaticon.com/) - Wide variety of icon sets, most of which are free to use
- [SVG Repo](https://www.svgrepo.com/) - 300,000+ Vector Icons
If you are a student, then you can get free access to premium icons on [Icon Scout](https://education.github.com/pack/redeem/iconscout-student) or [Icons8](https://icons8.com/github-students) using the [GitHub Student Pack](https://education.github.com/pack).
---
## Notes
If you are using icons from an external source, these will be fetched on initial page load automatically, if and when needed. But combining icons from multiple services may have a negative impact on performance.
You can improve load speeds, by downloading your required icons, and serving them locally. Scaling icons to the minimum required dimensions (e.g. 128x128 or 64x64) will also greatly improve application load times.
For icons from external sources, please see the Privacy Policies and Licenses for that provider.

View File

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

View File

@ -1,5 +1,7 @@
# Privacy & Security
Dashy was built with privacy in mind. Self-hosting your own apps and services is a great way to protect yourself from the mass data collection employed by big tech companies, and Dashy was designed to keep your local services organized and accessible from a single place.
Dashy was built with privacy in mind.
Self-hosting your own apps and services is a great way to protect yourself from the mass data collection employed by big tech companies, and Dashy was designed to keep your local services organized and accessible from a single place.
It's fully open source, and I've tried to keep to code as clear and thoroughly documented as possible, which will make it easy for you to understand exactly how it works, and what goes on behind the scenes.
@ -8,7 +10,7 @@ For privacy and security tips, check out another project of mine: **[Personal Se
---
## External Requests
By default, Dashy will not make any external requests, unless you configure it to. Some features (which are all off by default) do require internat access, and this section outlines those features, the services used, and links to their privacy policies.
By default, Dashy will not make any external requests, unless you configure it to. Some features (which are off by default) do require internat access, and this section outlines those features, the services used, and links to their privacy policies.
### Font Awesome
If either any of your sections or items are using font-awesome icons, then these will be fetched directly from font-awesome on page load. See the [Font Awesome Privacy Policy](https://fontawesome.com/privacy) for more info.
@ -43,6 +45,8 @@ Enabling anonymous error reporting helps me to discover bugs I was unaware of, a
If you need to monitor bugs yourself, then you can [self-host your own Sentry Server](https://develop.sentry.dev/self-hosted/), and use it by setting `appConfig.sentryDsn` to your Sentry instances [Data Source Name](https://docs.sentry.io/product/sentry-basics/dsn-explainer/), then just enable error reporting in Dashy.
### Widgets
---
## Local Storage
@ -79,7 +83,7 @@ As with most web projects, Dashy relies on several [dependencies](https://github
Dependencies can introduce security vulnerabilities, but since all these packages are open source any issues are usually very quickly spotted. Dashy is using Snyk for dependency security monitoring, and you can see [the latest report here](https://snyk.io/test/github/lissy93/dashy). If any issue is detected by Snyk, a note about it will appear at the top of the Reamde, and will usually be fixed within 48 hours.
Note that packages listed under `deDependencies` section are only used for building the project, and are not included in the production environment.
Note that packages listed under `devDependencies` section are only used for building the project, and are not included in the production environment.
---

View File

@ -18,12 +18,13 @@
### Feature Docs
- [Authentication](/docs/authentication.md) - Guide to setting up authentication to protect your dashboard
- [Alternate Views](/docs/alternate-views.md) - Outline of available pages / views and item opening methods
- [Backup & Restore](/docs/backup-restore.md) - Guide to Dashy's cloud sync feature
- [Icons](/docs/icons.md) - Outline of all available icon types for sections and items
- [Backup & Restore](/docs/backup-restore.md) - Guide to backing up config with Dashy's cloud sync feature
- [Icons](/docs/icons.md) - Outline of all available icon types for sections and items, with examples
- [Language Switching](/docs/multi-language-support.md) - Details on how to switch language, or add a new locale
- [Status Indicators](/docs/status-indicators.md) - Using Dashy to monitor uptime and status of your apps
- [Searching & Shortcuts](/docs/searching.md) - Finding and launching your apps, and using keyboard shortcuts
- [Theming](/docs/theming.md) - Complete guide to applying, writing and modifying themes and styles
- [Searching & Shortcuts](/docs/searching.md) - Searching, launching methods + keyboard shortcuts
- [Theming](/docs/theming.md) - Complete guide to applying, writing and modifying themes + styles
- [Widgets](/docs/widgets.md) - List of all dynamic content widgets, with usage guides and examples
### Misc
- [Privacy & Security](/docs/privacy.md) - List of requests, potential issues, and security resources

View File

@ -1,132 +1,171 @@
# *Dashy Showcase* 🌟
| 💗 Do you use Dashy? Got a sweet dashboard? Submit it to the showcase! 👉 [See How](#submitting-your-dashboard) |
|-|
### Home Lab 2.0
![screenshot-homelab](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/1-home-lab-material.png)
---
### Networking Services
> By [@Lissy93](https://github.com/lissy93)
![screenshot-networking-services](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/2-networking-services-minimal-dark.png)
---
### Homelab & VPS dashboard
> By [@shadowking001](https://github.com/shadowking001)
![screenshot-shadowking001-dashy](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/8-shadowking001s-dashy.png)
---
### EVO Dashboard
> By [@EVOTk](https://github.com/EVOTk)
![screenshot-evo-dashboard](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/12-evo-dashboard.png)
---
### NAS Home Dashboard
> By [@cerealconyogurt](https://github.com/cerealconyogurt)
![screenshot-networking-services](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/6-nas-home-dashboard.png)
---
### Dashy Live
> By [@Lissy93](https://github.com/lissy93)
> A dashboard I made to manage all project development links from one place. View demo at [live.dashy.to](https://live.dashy.to/).
![screenshot-dashy-live](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/10-dashy-live.png)
### CFT Toolbox
![screenshot-cft-toolbox](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/3-cft-toolbox.png)
---
### Bookmarks
![screenshot-bookmarks](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/4-bookmarks-colourful.png)
---
### Project Management
![screenshot-project-managment](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/5-project-managment.png)
---
### Dashy Example
> An example dashboard, by [@Lissy93](https://github.com/lissy93). View live at [demo.dashy.to](https://demo.dashy.to/).
![screenshot-dashy-example](https://i.ibb.co/YbzqPK7/demo-dashy.png)
---
### First Week of Self-Hosting
> By [u//RickyCZ](https://www.reddit.com/user/RickyCZ)
![screenshot-week-of-self-hosting](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/11-ricky-cz.png)
---
### HomeLAb 3.0
> By [@skoogee](https://github.com/skoogee) (http://zhrn.cc)
> Dashy, is the most complete dashboard I ever tried, has all the features, and it sets itself apart from the rest. It is my default homepage now. I am thankful to the developer @Lissy93 for sharing such a wonderful creation.
[![screenshot-12-skoogee-homelab-3](https://i.ibb.co/F5yBTsT/12-skoogee-homelab-3.png)](https://ibb.co/album/ynSwzm)
---
### Ground Control
> By [@dtctek](https://github.com/dtctek)
![screenshot-ground-control](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/7-ground-control-dtctek.png)
---
### Yet Another Homelab
![screenshot-yet-another-homelab](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/9-home-lab-oblivion.png)
---
## Submitting your Dashboard
#### How to Submit
- [Open an Issue](https://git.io/JEtgM)
- [Open a PR](https://github.com/Lissy93/dashy/compare)
#### What to Include
Please include the following information:
- A single high-quality screenshot of your Dashboard
- A short title (it doesn't have to be particularly imaginative)
- An optional description, you could include details on anything interesting or unique about your dashboard, or say how you use it, and why it's awesome
- Optionally leave your name or username, with a link to your GitHub, Twitter or Website
#### Template
If you're submitting a pull request, please use a format similar to this:
```
### [Dashboard Name] (required)
> Submitted by [@username](https://github.com/user) (optional)
![dashboard-screenshot](/docs/showcase/screenshot-name.jpg) (required)
[An optional text description, or any interesting details] (optional)
---
```
# *Dashy Showcase* 🌟
| 💗 Got a sweet dashboard? Submit it to the showcase! 👉 [See How](#submitting-your-dashboard) |
|-|
### Home Lab 2.0
![screenshot-homelab](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/1-home-lab-material.png)
---
### Ratty222
> By [@ratty222](https://github.com/ratty222) <sup>[#384](https://github.com/Lissy93/dashy/discussions/384)</sup>
![screenshot-ratty222-dashy](https://user-images.githubusercontent.com/1862727/147582551-4c655d37-8bcc-4f95-ab41-164a9d0d6a07.png)
---
### Networking Services
> By [@Lissy93](https://github.com/lissy93)
![screenshot-networking-services](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/2-networking-services-minimal-dark.png)
---
### Homelab & VPS dashboard
> By [@shadowking001](https://github.com/shadowking001)
![screenshot-shadowking001-dashy](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/8-shadowking001s-dashy.png)
---
### EVO Dashboard
> By [@EVOTk](https://github.com/EVOTk)
![screenshot-evo-dashboard](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/12-evo-dashboard.png)
---
### The Private Dashboard
> By [@DylanBeMe](https://github.com/DylanBeMe) <sup>[#419](https://github.com/Lissy93/dashy/issues/419)</sup>
![screenshot-evo-dashboard](https://i.ibb.co/hKS483T/private-dashboard-Dylan-Be-Me.png)
---
### NAS Home Dashboard
> By [@cerealconyogurt](https://github.com/cerealconyogurt)
![screenshot-networking-services](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/6-nas-home-dashboard.png)
---
### Dashy Live
> By [@Lissy93](https://github.com/lissy93)
> A dashboard I made to manage all project development links from one place. View demo at [live.dashy.to](https://live.dashy.to/).
![screenshot-dashy-live](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/10-dashy-live.png)
---
### System Monitor
> An aggregated board for monitoring system resource usage from a single view
![screenshot-monitor](https://i.ibb.co/xfK6BGb/system-monitor-board.png)
---
### CFT Toolbox
![screenshot-cft-toolbox](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/3-cft-toolbox.png)
---
### Bookmarks
![screenshot-bookmarks](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/4-bookmarks-colourful.png)
---
### Project Management
![screenshot-project-managment](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/5-project-managment.png)
---
### Dashy Example
> An example dashboard, by [@Lissy93](https://github.com/lissy93). View live at [demo.dashy.to](https://demo.dashy.to/).
![screenshot-dashy-example](https://i.ibb.co/YbzqPK7/demo-dashy.png)
---
### First Week of Self-Hosting
> By [u//RickyCZ](https://www.reddit.com/user/RickyCZ)
![screenshot-week-of-self-hosting](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/11-ricky-cz.png)
---
### HomeLAb 3.0
> By [@skoogee](https://github.com/skoogee) (http://zhrn.cc)
> Dashy, is the most complete dashboard I ever tried, has all the features, and it sets itself apart from the rest. It is my default homepage now. I am thankful to the developer @Lissy93 for sharing such a wonderful creation.
[![screenshot-12-skoogee-homelab-3](https://i.ibb.co/F5yBTsT/12-skoogee-homelab-3.png?)](https://ibb.co/album/ynSwzm)
---
### Ground Control
> By [@dtctek](https://github.com/dtctek)
![screenshot-ground-control](https://user-images.githubusercontent.com/1862727/149821995-e9b41dab-186c-42e6-b5b3-e233259b241d.png)
---
### Morning Dashboard
> Displayed on my smart screen between 05:00 - 08:00, and includes all the info that I usually check before leaving for work
![screenshot-morning-dash](https://i.ibb.co/4Wx8zb7/morning-dashboard.png)
---
### Crypto Dash
> Example usage of widgets to monitor cryptocurrencies news, prices and data. Config is [available here](https://gist.github.com/Lissy93/000f712a5ce98f212817d20bc16bab10#file-example-8-dashy-crypto-widgets-conf-yml)
![screenshot-crypto-dash](https://user-images.githubusercontent.com/1862727/147394584-352fe3bf-740d-4624-a01b-9003a97bc832.png)
---
### Yet Another Homelab
![screenshot-yet-another-homelab](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/9-home-lab-oblivion.png)
---
## Submitting your Dashboard
#### How to Submit
- [Open an Issue](https://git.io/JEtgM)
- [Open a PR](https://github.com/Lissy93/dashy/compare)
#### What to Include
Please include the following information:
- A single high-quality screenshot of your Dashboard
- A short title (it doesn't have to be particularly imaginative)
- An optional description, you could include details on anything interesting or unique about your dashboard, or say how you use it, and why it's awesome
- Optionally leave your name or username, with a link to your GitHub, Twitter or Website
#### Template
If you're submitting a pull request, please use a format similar to this:
```
### [Dashboard Name] (required)
> Submitted by [@username](https://github.com/user) (optional)
[An optional text description, or any interesting details] (optional)
![dashboard-screenshot](https://example.com/url-to-screenshot.png) (required)
---
```

View File

@ -1,6 +1,31 @@
# Troubleshooting
This document contains common problems and their solutions.
> _**This document contains common problems and their solutions.**_
>
> _If you came across an issue where the solution was not immediately obvious, consider adding it to this list to help other users._
### Contents
- [Refused to Connect in Web Content View](#refused-to-connect-in-modal-or-workspace-view)
- [404 On Static Hosting](#404-on-static-hosting)
- [Yarn Build or Run Error](#yarn-error)
- [Auth Validation Error: "should be object"](#auth-validation-error-should-be-object)
- [Config Not Updating](#config-not-updating)
- [Config Still not Updating](#config-still-not-updating)
- [Styles and Assets not Updating](#styles-and-assets-not-updating)
- [DockerHub toomanyrequests](#dockerhub-toomanyrequests)
- [Config Validation Errors](#config-validation-errors)
- [Node Sass unsupported environment](#node-sass-does-not-yet-support-your-current-environment)
- [Cannot find module './_baseValues'](#error-cannot-find-module-_basevalues)
- [Ngrok Invalid Host Headers](#invalid-host-header-while-running-through-ngrok)
- [Warnings in the Console during deploy](#warnings-in-the-console-during-deploy)
- [Docker Login Fails on Ubuntu](#docker-login-fails-on-ubuntu)
- [Status Checks Failing](#status-checks-failing)
- [Diagnosing Widget Errors](#widget-errors)
- [Fixing Widget CORS Errors](#widget-cors-errors)
- [How-To Open Browser Console](#how-to-open-browser-console)
- [Git Contributions not Displaying](#git-contributions-not-displaying)
---
## `Refused to Connect` in Modal or Workspace View
This is not an issue with Dashy, but instead caused by the target app preventing direct access through embedded elements. It can be fixed by setting the [`X-Frame-Options`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) HTTP header set to `ALLOW [path to Dashy]` or `SAMEORIGIN`, as defined in [RFC-7034](https://datatracker.ietf.org/doc/html/rfc7034). These settings are usually set in the config file for the web server that's hosting the target application, here are some examples of how to enable cross-origin access with common web servers:
@ -191,3 +216,80 @@ Currently, the status check needs a page to be rendered, so if this URL in your
For further troubleshooting, use an application like [Postman](https://postman.com) to diagnose the issue. Set the parameter to `GET`, and then make a call to: `https://[url-of-dashy]/status-check/?&url=[service-url]`. Where the service URL must have first been encoded (e.g. with `encodeURIComponent()` or [urlencoder.io](https://www.urlencoder.io/))
If you're serving Dashy though a CDN, instead of using the Node server or Docker image, then the Node endpoint that makes requests will not be available to you, and all requests will fail. A workaround for this may be implemented in the future, but in the meantime, your only option is to use the Docker or Node deployment method.
---
## Widget Errors
If an error occurs when fetching or rendering results, you will see a short message in the UI. If that message doesn't addequatley explain the problem, then you can [open the browser console](/docs/troubleshooting.md#how-to-open-browser-console) to see more details.
Before proceeding, ensure that if the widget requires auth your API is correct, and for custom widgets, double check that the URL and protocol is correct.
If the console message mentions to corss-origin blocking, then this is a CORS error, see: [Fixing Widget CORS Errors](#widget-cors-errors)
If you're able to, you can find more information about why the request may be failing in the Dev Tools under the Network tab, and you can ensure your endpoint is correct and working using a tool like Postman.
---
## Widget CORS Errors
The most common widget issue is a CORS error. This is a browser security mechanism which prevents the client-side app (Dashy) from from accessing resources on a remote origin, without that server's explicit permission (e.g. with headers like Access-Control-Allow-Origin). See the MDN Docs for more info: [Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS).
There are several ways to fix a CORS error:
#### Option 1 - Ensure Correct Protocol
You will get a CORS error if you try and access a http service from a https source. So ensure that the URL you are requesting has the right protocol, and is correctly formatted.
#### Option 2 - Set Headers
If you have control over the destination (e.g. for a self-hosted service), then you can simply apply the correct headers.
Add the `Access-Control-Allow-Origin` header, with the value of either `*` to allow requests from anywhere, or more securely, the host of where Dashy is served from. For example:
```
Access-Control-Allow-Origin: https://url-of-dashy.local
```
or
```
Access-Control-Allow-Origin: *
```
#### Option 3 - Proxying Request
You can route requests through Dashy's built-in CORS proxy. Instructions and more details can be found [here](/docs/widgets.md#proxying-requests). If you don't have control over the target origin, and you are running Dashy either through Docker, with the Node server or on Netlify, then this solution will work for you.
Just add the `useProxy: true` option to the failing widget.
#### Option 4 - Use a plugin
For testing purposes, you can use an addon, which will disable the CORS checks. You can get the Allow-CORS extension for [Chrome](https://chrome.google.com/webstore/detail/allow-cors-access-control/lhobafahddgcelffkeicbaginigeejlf?hl=en-US) or [Firefox](https://addons.mozilla.org/en-US/firefox/addon/access-control-allow-origin/), more details [here](https://mybrowseraddon.com/access-control-allow-origin.html)
---
## How-To Open Browser Console
When raising a bug, one crucial piece of info needed is the browser's console output. This will help the developer diagnose and fix the issue.
If you've been asked for this info, but are unsure where to find it, then it is under the "Console" tab, in the browsers developer tools, which can be opened with <kbd>F12</kbd>. You can right-click the console, and select Save As to download the log.
To open dev tools, and jump straight to the console:
- Win / Linux: <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>J</kbd>
- MacOS: <kbd>Cmd</kbd> + <kbd>Option</kbd> + <kbd>J</kbd>
For more detailed walk through, see [this article](https://support.shortpoint.com/support/solutions/articles/1000222881-save-browser-console-file).
---
## Git Contributions not Displaying
If you've contributed to Dashy (or any other project), but your contributions are not showing up on your GH profile, or in Dashy's [Credits Page](https://github.com/Lissy93/dashy/blob/master/docs/credits.md), then this is likely a git config issue.
These statistics are generated using the username / email associated with commits. This info needs to be setup on your local machine using [`git config`](https://git-scm.com/docs/git-config).
Run the following commands (replacing name + email with your info):
- `git config --global user.name "John Doe"`
- `git config --global user.email johndoe@example.com`
For more info, see [Git First Time Setup Docs](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup).
Note that only contributions to the master / main branch or a project are counted

1759
docs/widgets.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +1,48 @@
# Enables you to easily deploy a fork of Dashy to Netlify
# without the need to configure anything in admin UI
# Docs: https://www.netlify.com/docs/netlify-toml-reference/
# Essential site config
[build]
base = "/"
command = "yarn build"
publish = "dist"
functions = "services/serverless-functions"
# Site info, used for the 1-Click deploy page
[template.environment]
STATUSKIT_PAGE_TITLE = "Dashy"
STATUSKIT_COMPANY_LOGO = "https://raw.githubusercontent.com/Lissy93/dashy/master/docs/assets/logo.png"
STATUSKIT_SUPPORT_CONTACT_LINK = "https://github.com/lissy93/dashy"
STATUSKIT_RESOURCES_LINK = "https://dashy.to/docs"
# Redirect the Node endpoints to serverless functions
[[redirects]]
from = "/status-check"
to = "/.netlify/functions/cloud-status-check"
status = 301
force = true
[[redirects]]
from = "/config-manager/*"
to = "/.netlify/functions/not-supported"
status = 301
force = true
# For router history mode, ensure pages land on index
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Set any security headers here
[[headers]]
for = "/*"
[headers.values]
# Uncomment to enable Netlify user control. You must have a paid plan.
# Basic-Auth = "someuser:somepassword anotheruser:anotherpassword"
# Enables you to easily deploy a fork of Dashy to Netlify
# without the need to configure anything in admin UI
# Docs: https://www.netlify.com/docs/netlify-toml-reference/
# Essential site config
[build]
base = "/"
command = "yarn build"
publish = "dist"
functions = "services/serverless-functions"
# Site info, used for the 1-Click deploy page
[template.environment]
STATUSKIT_PAGE_TITLE = "Dashy"
STATUSKIT_COMPANY_LOGO = "https://raw.githubusercontent.com/Lissy93/dashy/master/docs/assets/logo.png"
STATUSKIT_SUPPORT_CONTACT_LINK = "https://github.com/lissy93/dashy"
STATUSKIT_RESOURCES_LINK = "https://dashy.to/docs"
# Redirect the Node endpoints to serverless functions
[[redirects]]
from = "/status-check"
to = "/.netlify/functions/cloud-status-check"
status = 301
force = true
[[redirects]]
from = "/config-manager/*"
to = "/.netlify/functions/not-supported"
status = 301
force = true
[[redirects]]
from = "/cors-proxy"
to = "/.netlify/functions/netlify-cors"
status = 301
force = true
# For router history mode, ensure pages land on index
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Set any security headers here
[[headers]]
for = "/*"
[headers.values]
# Uncomment to enable Netlify user control. You must have a paid plan.
# Basic-Auth = "someuser:somepassword anotheruser:anotherpassword"

View File

@ -1,6 +1,6 @@
{
"name": "Dashy",
"version": "1.9.3",
"version": "1.9.9",
"license": "MIT",
"main": "server",
"author": "Alicia Sykes <alicia@omg.lol> (https://aliciasykes.com)",
@ -25,6 +25,7 @@
"connect-history-api-fallback": "^1.6.0",
"crypto-js": "^4.1.1",
"express": "^4.17.1",
"frappe-charts": "^1.6.2",
"js-yaml": "^4.1.0",
"keycloak-js": "^15.0.2",
"register-service-worker": "^1.6.2",

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,69 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<font id="WeatherIcons" horiz-adv-x="514.451">
<font-face font-family="WeatherIcons"
units-per-em="512" ascent="512"
descent="0" />
<missing-glyph horiz-adv-x="0" />
<glyph glyph-name="01d"
unicode="&#xEA02;"
horiz-adv-x="512" d=" M256 132.414C324.414 132.414 379.586 188.028 379.586 256C379.586 323.972 323.972 379.586 256 379.586C188.028 379.586 132.414 323.972 132.414 256C132.414 188.028 187.586 132.414 256 132.414zM256 343.834C304.552 343.834 343.834 304.552 343.834 256C343.834 207.448 304.552 168.166 256 168.166C207.448 168.166 168.166 207.448 168.166 256C168.166 304.552 207.448 343.834 256 343.834z M274.097 436.966L274.097 493.903C274.097 504.055 266.152 512 256 512C245.848 512 237.903 504.055 237.903 493.903L237.903 436.966C237.903 426.814 245.848 418.8690000000001 256 418.8690000000001C266.152 418.8690000000001 274.097 426.814 274.097 436.966z M237.903 75.034L237.903 18.097C237.903 7.945 245.848 0 256 0C266.152 0 274.097 7.945 274.097 18.097L274.097 75.034C274.097 85.186 266.152 93.131 256 93.131C245.848 93.131 237.903 85.186 237.903 75.034z M396.8 371.2L436.966 411.366C444.028 418.428 444.028 429.903 436.966 436.966C429.903 444.028 418.428 444.028 411.366 436.966L371.2 396.8C364.138 389.738 364.138 378.262 371.2 371.2C374.731 367.669 379.145 365.903 384 365.903C388.855 365.903 393.269 367.669 396.8 371.2L396.8 371.2z M75.034 75.034C78.566 71.503 82.979 69.738 87.834 69.738C92.248 69.738 97.103 71.503 100.634 75.034L140.8 115.2C147.862 122.262 147.862 133.738 140.8 140.8C133.738 147.862 122.262 147.862 115.2 140.8L75.034 100.634C67.972 93.572 67.972 82.097 75.034 75.034z M512 256C512 266.152 504.055 274.097 493.903 274.097L436.966 274.097C426.814 274.097 418.8690000000001 266.152 418.8690000000001 256C418.8690000000001 245.848 426.814 237.903 436.966 237.903L493.903 237.903C503.614 237.903 512 245.848 512 256z M18.097 237.903L75.034 237.903C85.186 237.903 93.131 245.848 93.131 256C93.131 266.152 85.186 274.097 75.034 274.097L18.097 274.097C7.945 274.097 0 266.152 0 256C0 245.848 8.386 237.903 18.097 237.903z M424.166 69.738C428.579 69.738 433.4340000000001 71.503 436.966 75.034C444.028 82.097 444.028 93.572 436.966 100.634L396.8 140.8C389.738 147.862 378.262 147.862 371.2 140.8C364.138 133.738 364.138 122.262 371.2 115.2L411.366 75.034C414.897 71.503 419.752 69.738 424.166 69.738L424.166 69.738z M140.8 371.2C147.862 378.262 147.862 389.738 140.8 396.8L100.634 436.966C93.572 444.028 82.097 444.028 75.034 436.966C67.972 429.903 67.972 418.428 75.034 411.366L115.2 371.2C118.731 367.669 123.145 365.903 128 365.903C132.855 365.903 137.269 367.669 140.8 371.2z" />
<glyph glyph-name="01n"
unicode="&#xEA01;"
horiz-adv-x="512" d=" M337.556 322.748L332.533 293.456C331.759 288.958 333.572 284.499 337.256 281.828C339.3 280.341 341.716 279.555 344.246 279.555C346.168 279.555 348.082 280.029 349.778 280.921L376.089 294.752L402.386 280.926C404.087 280.029 406.004 279.554 407.93 279.554C410.451 279.554 412.863 280.336 414.918 281.823C418.608 284.505 420.421 288.962 419.647 293.45L414.623 322.747L435.907 343.495C439.1739999999999 346.68 440.3259999999999 351.353 438.9149999999999 355.676C437.5079999999999 360.013 433.8319999999999 363.115 429.318 363.77L399.902 368.045L386.7459999999999 394.705C384.728 398.788 380.644 401.325 376.0899999999999 401.325C371.529 401.325 367.4439999999999 398.785 365.431 394.699L352.274 368.044L322.8589999999999 363.769C318.353 363.115 314.6769999999999 360.017 313.2609999999999 355.677C311.8549999999999 351.347 313.0059999999999 346.681 316.2699999999999 343.492L337.556 322.748zM358.816 352.627C361.455 353.01 363.735 354.6670000000001 364.916 357.058L376.089 379.6950000000001L387.26 357.0590000000001C388.438 354.668 390.72 353.011 393.359 352.6280000000001L418.343 348.997L400.266 331.376C398.358 329.515 397.486 326.8350000000001 397.936 324.207L402.203 299.326L379.858 311.074C378.678 311.694 377.384 312.004 376.089 312.004C374.794 312.004 373.499 311.694 372.318 311.072L349.974 299.326L354.24 324.206C354.691 326.834 353.82 329.515 351.911 331.376L333.832 348.996L358.816 352.627z M445.893 82.507C437.604 81.607 429.239 81.152 421.029 81.152C366.835 81.152 314.324 100.501 273.1670000000001 135.635C269.7650000000001 138.539 264.651 138.134 261.7480000000001 134.733C258.8440000000001 131.33 259.2480000000001 126.217 262.6500000000001 123.313C306.737 85.677 362.9840000000001 64.95 421.029 64.95C421.66 64.95 422.293 64.953 422.925 64.958C381.373 33.367 330.735 16.199 277.985 16.199C145.759 16.199 38.185 123.774 38.185 256C38.185 383.344 137.957 487.8209999999999 263.437 495.3639999999999C208.891 449.204 176.967 381.203 176.967 309.01C176.967 246.365 200.665 186.814 243.695 141.3229999999999C246.768 138.0729999999999 251.894 137.9309999999999 255.146 141.005C258.396 144.0799999999999 258.538 149.2049999999999 255.464 152.456C215.291 194.925 193.167 250.524 193.167 309.01C193.167 384.148 230.215 454.4299999999999 292.272 497.015C295.15 498.99 296.439 502.5889999999999 295.47 505.943C294.501 509.297 291.49 511.653 288.002 511.788C284.296 511.931 281.02 512 277.986 512C209.604 512 145.317 485.371 96.966 437.02C48.614 388.668 21.986 324.381 21.986 256.001S48.614 123.333 96.966 74.982C145.317 26.629 209.605 0 277.986 0C342.874 0 404.773 24.335 452.282 68.523C454.837 70.9 455.585 74.645 454.133 77.8200000000001C452.683 80.995 449.3690000000001 82.882 445.893 82.507z M327.465 186.76C327.465 168.623 342.222 153.867 360.36 153.867C378.497 153.867 393.253 168.6229999999999 393.253 186.76C393.253 204.898 378.497 219.654 360.3599999999999 219.654C342.222 219.654 327.465 204.898 327.465 186.76zM377.053 186.76C377.053 177.555 369.564 170.067 360.36 170.067C351.154 170.067 343.665 177.556 343.665 186.76S351.1550000000001 203.454 360.36 203.454S377.053 195.966 377.053 186.76z M472.699 255.998C463.138 255.998 455.386 248.248 455.386 238.684C455.386 229.121 463.137 221.369 472.699 221.369C482.264 221.369 490.014 229.121 490.014 238.684C490.014 248.249 482.264 255.998 472.699 255.998z" />
<glyph glyph-name="02d"
unicode="&#xEA03;"
horiz-adv-x="512" d=" M326.4 467.2C315.795 467.2 307.2 458.604 307.2 448V435.2C307.2 424.596 315.795 416 326.4 416C337.002 416 345.6 424.596 345.6 435.2V448C345.6 458.604 337.002 467.2 326.4 467.2z M422.4 281.6C422.4 334.534 379.334 377.6 326.4 377.6C305.455 377.6 285.548 370.971 268.829 358.428C258.714 350.839 250.172 341.233 243.746 330.3930000000001C229.643 336.058 214.314 339.148 198.398 339.148C133.21 339.148 79.838 287.588 76.926 223.106C33.499 216.934 0 179.506 0 134.4C0 84.993 40.195 44.8 89.6 44.8H313.6C373.5920000000001 44.8 422.4000000000001 93.608 422.4000000000001 153.6C422.4000000000001 178.482 413.985 201.423 399.877 219.773C414.363 236.897 422.4 258.565 422.4 281.6zM313.6 83.2H89.6C61.37 83.2 38.4 106.168 38.4 134.4S61.37 185.6 89.6 185.6H96C106.605 185.6 115.2 194.196 115.2 204.8V217.549C115.2 263.427 152.525 300.749 198.4 300.749C236.37 300.749 269.5 275.112 278.967 238.405C281.155 229.925 288.803 224 297.559 224H313.6C352.42 224 384 192.419 384 153.6C384 114.781 352.42 83.2 313.6 83.2zM371.443 245.6840000000001C354.673 256.256 334.848 262.4 313.6 262.4H311.468C303.927 281.4600000000001 291.814 297.923 276.625 310.669C286.755 328.1330000000001 305.445 339.2000000000001 326.4 339.2000000000001C358.161 339.2000000000001 384 313.362 384 281.6C384 268.303 379.525 255.77 371.443 245.6840000000001z M492.8 300.8H480C469.395 300.8 460.8 292.204 460.8 281.6S469.395 262.4000000000001 480 262.4000000000001H492.8C503.402 262.4000000000001 512 270.9960000000001 512 281.6S503.402 300.8 492.8 300.8z M457.637 412.841C450.14 420.337 437.983 420.337 430.484 412.838L421.434 403.787C413.937 396.289 413.937 384.131 421.437 376.633C425.185 372.885 430.097 371.01 435.011 371.01C439.924 371.01 444.839 372.885 448.588 376.636L457.638 385.687C465.138 393.187 465.138 405.344 457.637 412.841z M231.364 403.789L222.314 412.839C214.814 420.336 202.66 420.337 195.159 412.839C187.662 405.342 187.662 393.1860000000001 195.159 385.6860000000001L204.21 376.636C207.96 372.886 212.873 371.01 217.787 371.01C222.7 371.01 227.615 372.885 231.361 376.634C238.861 384.132 238.861 396.288 231.364 403.789z" />
<glyph glyph-name="02n"
unicode="&#xEA04;"
horiz-adv-x="514.451" d=" M513.493 206.586C511.776 210.732 507.979 213.648 503.53 214.24C462.581 219.674 427.344 246.707 411.568 284.791C395.793 322.876 401.592 366.911 426.705 399.708C429.433 403.271 430.057 408.019 428.34 412.166C426.623 416.312 422.826 419.228 418.377 419.818C393.88 423.072 368.837 419.781 345.96 410.306C302.692 392.384 272.2200000000001 355.1040000000001 262.326 311.304C242.283 333.942 212.747 347.953 181.04 347.953C121.795 347.953 73.595 300.707 73.595 242.636C73.595 242.431 73.595 242.229 73.597 242.024C33.858 239.0350000000001 2.451 206.35 2.451 166.602C2.451 124.894 37.032 90.963 79.539 90.963L286.34 90.963C323.599 90.963 356.057 111.472 372.609 141.578C381.7320000000001 139.756 390.917 138.833 400.082 138.833C418.364 138.833 436.552 142.455 453.926 149.651C476.803 159.128 496.835 174.508 511.86 194.13C514.588 197.691 515.212 202.44 513.493 206.586zM286.338 116.113L79.539 116.113C50.9 116.113 27.601 138.763 27.601 166.602C27.601 194.443 50.9 217.095 79.539 217.095C81.306 217.095 83.272 216.963 85.719 216.676C89.604 216.217 93.472 217.598 96.197 220.402C98.92 223.203 100.186 227.113 99.622 230.981C99.041 234.9700000000001 98.745 238.892 98.745 242.636C98.745 286.8400000000001 135.662 322.803 181.04 322.803C217.033 322.803 249.352 299.426 259.776 265.915C259.9100000000001 265.444 260.073 264.986 260.259 264.54C262.447 259.1190000000001 268.138 255.86 273.993 256.852C278.05 257.533 282.203 257.877 286.338 257.877C326.476 257.877 359.129 226.079 359.129 186.996C359.129 147.911 326.476 116.113 286.338 116.113zM444.3 172.887C424.226 164.572 402.8450000000001 162.085 381.772 165.486C383.394 172.405 384.281 179.598 384.281 186.994C384.281 239.947 340.344 283.025 286.34 283.025C285.575 283.025 284.811 282.973 284.046 282.957C285.136 328.456 312.724 369.315 355.586 387.068C367.95 392.19 381.085 395.111 394.352 395.751C374.641 358.833 372.078 314.409 388.335 275.165C404.588 235.926 437.817 206.324 477.856 194.157C468.019 185.229 456.666 178.009 444.3 172.887z" />
<glyph glyph-name="03d"
unicode="&#xEA05;"
horiz-adv-x="512" d=" M320 384C372.562 384 415.375 341.562 416 289.187C415.75 287.249 415.562 285.312 415.5 283.312L414.688 259.812L436.938 252.062C462.688 243.094 480 218.938 480 192C480 156.688 451.312 128 416 128H96C60.719 128 32 156.688 32 192C32 226.938 60.188 255.438 95 256C96.5 255.781 98.063 255.594 99.625 255.5L123.938 253.906L131.938 276.875C140.938 302.687 165.063 320 192 320C195.125 320 198.563 319.625 203.188 318.812L225.594 314.781L236.75 334.625C253.875 365.062 285.75 384 320 384M320 416C272.062 416 230.781 389.312 208.844 350.312C203.375 351.281 197.781 352 192 352C150.062 352 114.781 324.937 101.719 287.437C99.813 287.562 97.969 288 96 288C43 288 0 245 0 192S43 96 96 96H416C469 96 512 139 512 192C512 233.938 484.938 269.25 447.438 282.313C447.5 284.25 448 286.062 448 288C448 358.687 390.688 416 320 416L320 416z" />
<glyph glyph-name="03n"
unicode="&#xEA06;"
horiz-adv-x="512" d=" M320 384C372.562 384 415.375 341.562 416 289.187C415.75 287.249 415.562 285.312 415.5 283.312L414.688 259.812L436.938 252.062C462.688 243.094 480 218.938 480 192C480 156.688 451.312 128 416 128H96C60.719 128 32 156.688 32 192C32 226.938 60.188 255.438 95 256C96.5 255.781 98.063 255.594 99.625 255.5L123.938 253.906L131.938 276.875C140.938 302.687 165.063 320 192 320C195.125 320 198.563 319.625 203.188 318.812L225.594 314.781L236.75 334.625C253.875 365.062 285.75 384 320 384M320 416C272.062 416 230.781 389.312 208.844 350.312C203.375 351.281 197.781 352 192 352C150.062 352 114.781 324.937 101.719 287.437C99.813 287.562 97.969 288 96 288C43 288 0 245 0 192S43 96 96 96H416C469 96 512 139 512 192C512 233.938 484.938 269.25 447.438 282.313C447.5 284.25 448 286.062 448 288C448 358.687 390.688 416 320 416L320 416z" />
<glyph glyph-name="04d"
unicode="&#xEA07;"
horiz-adv-x="512" d=" M320 384C372.562 384 415.375 341.562 416 289.187C415.75 287.249 415.562 285.312 415.5 283.312L414.688 259.812L436.938 252.062C462.688 243.094 480 218.938 480 192C480 156.688 451.312 128 416 128H96C60.719 128 32 156.688 32 192C32 226.938 60.188 255.438 95 256C96.5 255.781 98.063 255.594 99.625 255.5L123.938 253.906L131.938 276.875C140.938 302.687 165.063 320 192 320C195.125 320 198.563 319.625 203.188 318.812L225.594 314.781L236.75 334.625C253.875 365.062 285.75 384 320 384M320 416C272.062 416 230.781 389.312 208.844 350.312C203.375 351.281 197.781 352 192 352C150.062 352 114.781 324.937 101.719 287.437C99.813 287.562 97.969 288 96 288C43 288 0 245 0 192S43 96 96 96H416C469 96 512 139 512 192C512 233.938 484.938 269.25 447.438 282.313C447.5 284.25 448 286.062 448 288C448 358.687 390.688 416 320 416L320 416z" />
<glyph glyph-name="04n"
unicode="&#xEA08;"
horiz-adv-x="512" d=" M320 384C372.562 384 415.375 341.562 416 289.187C415.75 287.249 415.562 285.312 415.5 283.312L414.688 259.812L436.938 252.062C462.688 243.094 480 218.938 480 192C480 156.688 451.312 128 416 128H96C60.719 128 32 156.688 32 192C32 226.938 60.188 255.438 95 256C96.5 255.781 98.063 255.594 99.625 255.5L123.938 253.906L131.938 276.875C140.938 302.687 165.063 320 192 320C195.125 320 198.563 319.625 203.188 318.812L225.594 314.781L236.75 334.625C253.875 365.062 285.75 384 320 384M320 416C272.062 416 230.781 389.312 208.844 350.312C203.375 351.281 197.781 352 192 352C150.062 352 114.781 324.937 101.719 287.437C99.813 287.562 97.969 288 96 288C43 288 0 245 0 192S43 96 96 96H416C469 96 512 139 512 192C512 233.938 484.938 269.25 447.438 282.313C447.5 284.25 448 286.062 448 288C448 358.687 390.688 416 320 416L320 416z" />
<glyph glyph-name="09d"
unicode="&#xEA09;"
horiz-adv-x="512" d=" M431.401 370.45C431.989 375.45 432.55 381.241 432.55 385.488C432.55 455.248 376.363 512 307.313 512C263.363 512 222.816 488.572 200.274 451.19C186.281 460.87 169.727 466.063 152.416 466.063C105.527 466.063 67.377 427.522 67.377 380.149C67.377 374.937 67.853 369.7390000000001 68.762 364.635C28.498 349.899 1.294 311.3350000000001 1.294 267.078C1.294 210.755 45.48 168.292 104.082 168.292L398.3210000000001 168.292C454.998 168.292 501.109 214.865 501.109 272.111C501.114 316.603 472.376 356.326 431.401 370.45zM398.326 192.054L104.082 192.054C58.889 192.054 24.816 224.319 24.816 267.083C24.816 304.371 49.835 336.396 85.659 344.988C88.884 345.746 91.638 347.846 93.239 350.775C94.849 353.703 95.151 357.173 94.105 360.337C91.977 366.693 90.904 373.35 90.904 380.149C90.904 414.419 118.498 442.282 152.406 442.282C168.701 442.282 184.068 435.907 195.66 424.335C198.4 421.618 202.284 420.366 206.065 421.1190000000001C209.86 421.806 213.056 424.358 214.648 427.875C231.107 464.533 267.492 488.215 307.308 488.215C363.382 488.215 409.009 442.145 409.009 385.488C409.009 379.659 407.366 367.776 406.74 363.689C405.784 357.4840000000001 409.739 351.603 415.784 350.233C451.594 342.131 477.597 309.2820000000001 477.597 272.111C477.588 227.972 442.041 192.054 398.326 192.054zM132.185 135.241C125.725 136.131 119.798 131.621 118.889 125.133L111.036 68.998C110.132 62.505 114.619 56.483 121.041 55.561C121.606 55.485 122.161 55.462 122.707 55.462C128.484 55.462 133.512 59.727 134.351 65.664L142.19 121.799C143.098 128.292 138.607 134.309 132.185 135.241zM213.565 135.241C207.105 136.131 201.178 131.621 200.269 125.133L184.577 13.55C183.668 7.057 188.146 1.036 194.568 0.113C195.133 0.014 195.688 0 196.239 0C202.006 0 207.035 4.27 207.873 10.198L223.565 121.785C224.474 128.292 220.006 134.281 213.565 135.241zM297.855 135.241C291.404 136.131 285.449 131.621 284.545 125.133L268.867 13.55C267.944 7.057 272.421 1.036 278.853 0.113C279.4220000000001 0.014 279.968 0 280.519 0C286.287 0 291.324 4.27 292.153 10.198L307.841 121.785C308.749 128.292 304.281 134.281 297.855 135.241zM379.225 135.241C372.742 136.131 366.838 131.621 365.929 125.133L358.076 68.998C357.182 62.505 361.654 56.483 368.09 55.561C368.641 55.485 369.192 55.462 369.743 55.462C375.525 55.462 380.548 59.727 381.391 65.664L389.235 121.799C390.129 128.292 385.642 134.309 379.225 135.241z" />
<glyph glyph-name="09n"
unicode="&#xEA0A;"
horiz-adv-x="512" d=" M431.401 370.45C431.989 375.45 432.55 381.241 432.55 385.488C432.55 455.248 376.363 512 307.313 512C263.363 512 222.816 488.572 200.274 451.19C186.281 460.87 169.727 466.063 152.416 466.063C105.527 466.063 67.377 427.522 67.377 380.149C67.377 374.937 67.853 369.7390000000001 68.762 364.635C28.498 349.899 1.294 311.3350000000001 1.294 267.078C1.294 210.755 45.48 168.292 104.082 168.292L398.3210000000001 168.292C454.998 168.292 501.109 214.865 501.109 272.111C501.114 316.603 472.376 356.326 431.401 370.45zM398.326 192.054L104.082 192.054C58.889 192.054 24.816 224.319 24.816 267.083C24.816 304.371 49.835 336.396 85.659 344.988C88.884 345.746 91.638 347.846 93.239 350.775C94.849 353.703 95.151 357.173 94.105 360.337C91.977 366.693 90.904 373.35 90.904 380.149C90.904 414.419 118.498 442.282 152.406 442.282C168.701 442.282 184.068 435.907 195.66 424.335C198.4 421.618 202.284 420.366 206.065 421.1190000000001C209.86 421.806 213.056 424.358 214.648 427.875C231.107 464.533 267.492 488.215 307.308 488.215C363.382 488.215 409.009 442.145 409.009 385.488C409.009 379.659 407.366 367.776 406.74 363.689C405.784 357.4840000000001 409.739 351.603 415.784 350.233C451.594 342.131 477.597 309.2820000000001 477.597 272.111C477.588 227.972 442.041 192.054 398.326 192.054zM132.185 135.241C125.725 136.131 119.798 131.621 118.889 125.133L111.036 68.998C110.132 62.505 114.619 56.483 121.041 55.561C121.606 55.485 122.161 55.462 122.707 55.462C128.484 55.462 133.512 59.727 134.351 65.664L142.19 121.799C143.098 128.292 138.607 134.309 132.185 135.241zM213.565 135.241C207.105 136.131 201.178 131.621 200.269 125.133L184.577 13.55C183.668 7.057 188.146 1.036 194.568 0.113C195.133 0.014 195.688 0 196.239 0C202.006 0 207.035 4.27 207.873 10.198L223.565 121.785C224.474 128.292 220.006 134.281 213.565 135.241zM297.855 135.241C291.404 136.131 285.449 131.621 284.545 125.133L268.867 13.55C267.944 7.057 272.421 1.036 278.853 0.113C279.4220000000001 0.014 279.968 0 280.519 0C286.287 0 291.324 4.27 292.153 10.198L307.841 121.785C308.749 128.292 304.281 134.281 297.855 135.241zM379.225 135.241C372.742 136.131 366.838 131.621 365.929 125.133L358.076 68.998C357.182 62.505 361.654 56.483 368.09 55.561C368.641 55.485 369.192 55.462 369.743 55.462C375.525 55.462 380.548 59.727 381.391 65.664L389.235 121.799C390.129 128.292 385.642 134.309 379.225 135.241z" />
<glyph glyph-name="10d"
unicode="&#xEA0B;"
horiz-adv-x="512" d=" M98.59 109.285C92.277 110.12 86.348 105.687 85.446 99.264L79.423 56.097C78.53 49.646 82.959 43.665 89.324 42.754C89.875 42.683 90.421 42.64 90.967 42.64C96.658 42.64 101.647 46.898 102.468 52.789L108.482 95.956C109.375 102.407 104.946 108.359 98.59 109.285zM160.972 109.285C154.649 110.182 148.735 105.715 147.819 99.264L135.795 13.452C134.903 7.001 139.322 1.03 145.678 0.119C146.238 0.019 146.784 0 147.335 0C153.026 0 158.015 4.244 158.831 10.125L170.855 95.956C171.757 102.378 167.328 108.359 160.972 109.285zM225.566 109.285C219.21 110.182 213.305 105.715 212.413 99.264L200.389 13.452C199.487 7.001 203.902 1.03 210.272 0.119C210.818 0.019 211.378 0 211.914 0C217.615 0 222.595 4.244 223.425 10.125L235.449 95.956C236.341 102.378 231.922 108.359 225.566 109.285zM287.924 109.285C281.592 110.12 275.673 105.687 274.776 99.264L268.766 56.097C267.86 49.646 272.288 43.665 278.649 42.754C279.209 42.683 279.75 42.64 280.291 42.64C285.992 42.64 290.981 46.898 291.807 52.789L297.8210000000001 95.956C298.718 102.407 294.28 108.359 287.924 109.285zM417.914 341.216C408.715 393.91 358.96 429.169 306.983 419.884C288.029 416.49 271.026 407.676 257.35 394.29C249.836 396.236 242.004 397.38 233.901 397.38C201.604 397.38 171.733 380.6430000000001 154.365 353.738C144.164 360.094 132.378 363.502 120.117 363.502C84.022 363.502 54.664 333.735 54.664 297.166C54.664 293.976 54.877 290.81 55.319 287.692C25.851 275.7820000000001 6.114 246.86 6.114 213.808C6.114 170.58 39.854 137.96 84.601 137.96L300.769 137.96C344.04 137.96 379.252 173.656 379.252 217.535C379.252 226.216 377.795 234.651 375.1600000000001 242.598C407.301 263.4360000000001 424.683 302.449 417.914 341.216zM300.769 161.523L84.601 161.523C53.111 161.523 29.378 183.999 29.378 213.808C29.378 239.882 46.803 262.2870000000001 71.757 268.2920000000001C74.947 269.0560000000001 77.662 271.13 79.257 274.031C80.837 276.9360000000001 81.151 280.391 80.116 283.534C78.649 287.891 77.928 292.486 77.928 297.176C77.928 320.748 96.858 339.939 120.117 339.939C131.295 339.939 141.828 335.558 149.793 327.579C152.494 324.892 156.339 323.677 160.07 324.389C163.82 325.082 166.986 327.603 168.557 331.101C180.163 357.0320000000001 205.82 373.793 233.901 373.793C273.461 373.793 305.634 341.197 305.634 301.101C305.634 297.442 304.642 289.771 303.987 285.5080000000001C303.033 279.366 306.954 273.5320000000001 312.93 272.175C337.879 266.483 355.998 243.504 355.998 217.53C355.998 186.643 331.224 161.523 300.769 161.523zM364.504 263.678C355.556 276.361 343.044 286.41 328.291 292.049C328.623 295.192 328.884 298.481 328.884 301.097C328.884 336.522 309.864 367.456 281.687 384.179C290.35 390.445 300.266 394.717 311.032 396.644C350.453 403.741 388.062 377.002 395.006 337.096C400.014 308.525 387.644 279.741 364.504 263.678zM323.834 455.309L323.905 455.309C330.332 455.362 335.496 460.669 335.463 467.195L335.249 500.29C335.197 506.76 330.004 512 323.62 512L323.544 512C317.117 511.953 311.948 506.641 311.981 500.119L312.194 467.024C312.247 460.545 317.435 455.309 323.834 455.309zM494.257 323.3590000000001L494.166 323.3590000000001L461.513 323.145C455.086 323.093 449.9220000000001 317.786 449.955 311.288C450.007 304.785 455.195 299.573 461.585 299.573L461.661 299.573L494.323 299.7870000000001C500.75 299.834 505.919 305.146 505.886 311.644C505.839 318.142 500.65 323.3590000000001 494.257 323.3590000000001zM427.963 413.12C430.963 413.12 433.963 414.283 436.2320000000001 416.642L459.178 440.1910000000001C463.692 444.814 463.635 452.286 459.074 456.857C454.517 461.428 447.154 461.409 442.635 456.762L419.694 433.19C415.171 428.5660000000001 415.218 421.0950000000001 419.789 416.524C422.054 414.264 425.011 413.12 427.963 413.12zM203.294 416.642C205.568 414.283 208.568 413.12 211.568 413.12C214.52 413.12 217.473 414.264 219.737 416.528C224.318 421.1 224.356 428.5710000000001 219.841 433.194L196.9 456.767C192.377 461.414 185.019 461.428 180.462 456.862C175.891 452.29 175.844 444.819 180.358 440.1960000000001L203.294 416.642zM423.435 223.402C418.921 228.049 411.544 228.091 406.997 223.501C402.411 218.925 402.373 211.478 406.892 206.831L429.838 183.258C432.112 180.923 435.1070000000001 179.76 438.1070000000001 179.76C441.06 179.76 444.012 180.875 446.276 183.163C450.857 187.734 450.895 195.187 446.381 199.834L423.435 223.402z" />
<glyph glyph-name="10n"
unicode="&#xEA0C;"
horiz-adv-x="512" d=" M114.55 115.983C107.778 116.872 101.386 112.191 100.421 105.329L93.953 59.512C92.989 52.679 97.755 46.349 104.584 45.374C105.178 45.275 105.763 45.256 106.347 45.256C112.482 45.256 117.833 49.761 118.722 56.01L125.176 101.831C126.145 108.689 121.393 115.004 114.55 115.983zM181.589 115.983C174.779 116.934 168.421 112.191 167.451 105.357L154.53 14.276C153.551 7.413 158.308 1.098 165.147 0.119C165.745 0.048 166.339 0 166.924 0C173.035 0 178.386 4.505 179.285 10.759L192.206 101.841C193.175 108.665 188.433 115.004 181.589 115.983zM251.015 115.983C244.224 116.934 237.846 112.191 236.882 105.357L223.96 14.276C222.981 7.413 227.738 1.098 234.577 0.119C235.171 0.048 235.755 0 236.349 0C242.466 0 247.817 4.505 248.715 10.759L261.636 101.841C262.61 108.665 257.853 115.004 251.015 115.983zM318.031 115.983C311.254 116.872 304.863 112.191 303.907 105.329L297.454 59.512C296.4700000000001 52.679 301.246 46.349 308.066 45.374C308.664 45.275 309.2440000000001 45.256 309.843 45.256C315.969 45.256 321.32 49.761 322.2080000000001 56.01L328.662 101.831C329.631 108.689 324.874 115.004 318.031 115.983zM481.779 393.424C481.779 393.9940000000001 481.75 394.564 481.708 395.111C481.608 400.381 478.262 405.005 473.301 406.7440000000001C468.316 408.46 462.827 406.887 459.515 402.814C446.741 387.127 427.836 378.117 407.682 378.117C370.819 378.117 340.818 408.151 340.818 445.052C340.818 462.098 347.305 478.38 359.062 490.892C362.674 494.708 363.496 500.378 361.139 505.102C358.772 509.797 353.64 512.582 348.517 511.897C285.606 504.479 243.35 460.24 243.35 401.769C243.35 400.9940000000001 243.445 400.243 243.578 399.502C215.473 396.94 190.02 381.743 174.58 358.666C164.762 364.483 153.518 367.581 141.827 367.581C106.214 367.581 77.225 338.55 77.225 302.889C77.225 300.099 77.392 297.334 77.748 294.62C49.244 282.815 30.221 254.977 30.221 223.18C30.221 181.127 63.425 149.42 107.454 149.42L316.734 149.42C359.318 149.42 393.962 184.107 393.962 226.729C393.962 245.159 387.252 262.462 376.032 276.024C434.423 286.327 481.779 336.7630000000001 481.779 393.424zM316.724 174.46L107.445 174.46C77.672 174.46 55.218 195.407 55.218 223.184C55.218 247.544 71.703 268.449 95.317 274.076C98.739 274.889 101.657 277.103 103.358 280.202C105.064 283.257 105.387 286.921 104.261 290.262C102.916 294.264 102.222 298.531 102.222 302.894C102.222 324.754 119.981 342.5610000000001 141.818 342.5610000000001C152.306 342.5610000000001 162.21 338.483 169.671 331.07C172.574 328.209 176.699 326.897 180.729 327.6860000000001C184.759 328.423 188.162 331.093 189.858 334.814C200.893 359.174 225.282 374.909 251.989 374.909C289.589 374.909 320.174 344.305 320.174 306.6670000000001C320.174 303.231 319.21 295.818 318.616 291.93C317.594 285.401 321.8 279.227 328.2200000000001 277.773C351.819 272.4840000000001 368.956 251.004 368.956 226.739C368.966 197.912 345.532 174.46 316.724 174.46zM354.238 299.101C351.962 299.101 349.847 298.4410000000001 348.008 297.376C346.915 297.871 345.832 298.379 344.715 298.8210000000001C344.986 301.558 345.19 304.372 345.19 306.6620000000001C345.19 352.683 311.692 390.915 267.847 398.48C268.142 399.535 268.342 400.623 268.342 401.769C268.342 437.52 289.28 466.461 322.683 479.834C318.197 468.913 315.831 457.142 315.831 445.057C315.831 394.355 357.052 353.092 407.701 353.092C423.165 353.092 438.077 356.9310000000001 451.289 364.079C437.189 327.178 397.8450000000001 299.101 354.238 299.101z" />
<glyph glyph-name="11d"
unicode="&#xEA0D;"
horiz-adv-x="512" d=" M411.247 366.3300000000001C405.951 439.213 342.843 496.943 266.038 496.943C205.107 496.943 151.411 460.914 129.988 406.511C128.499 406.565 126.998 406.5900000000001 125.489 406.5900000000001C56.295 406.593 0 350.299 0 281.101C0 211.903 56.295 155.609 125.492 155.609L143.733 155.609L141.236 149.226C140.031 146.138 140.422 142.652 142.295 139.907C144.167 137.168 147.275 135.529 150.589 135.529L250.981 135.529L250.981 25.097C250.981 20.401 254.236 16.332 258.814 15.302C259.549 15.136 260.295 15.057 261.019 15.057C264.8330000000001 15.057 268.401 17.233 270.087 20.792L334.27 156.113C335.092 155.896 335.892 155.606 336.7850000000001 155.606C361.735 155.606 411.599 155.733 412.089 155.75C468.107 158.633 512 204.873 512 261.019C512.003 317.586 467.221 363.886 411.247 366.3300000000001zM271.058 69.693L271.058 145.572C271.061 151.115 266.5710000000001 155.612 261.021 155.612L165.294 155.612L230.902 323.27L230.902 215.603C230.902 210.059 235.392 205.563 240.942 205.563L335.501 205.563L271.058 69.693zM411.543 175.818C411.543 175.818 369.739 175.711 343.556 175.691L360.443 211.295C361.924 214.409 361.698 218.055 359.854 220.966C358.021 223.875 354.815 225.637 351.374 225.637L250.981 225.637L250.981 376.476C250.981 381.305 247.549 385.4460000000001 242.805 386.339C238.059 387.24 233.354 384.627 231.589 380.134L151.591 175.694L127.119 175.703L125.492 175.694C67.365 175.694 20.08 222.982 20.08 281.107C20.08 339.231 67.365 386.519 125.492 386.519C128.992 386.519 132.463 386.347 135.884 386.015C140.501 385.534 144.924 388.389 146.394 392.857C162.991 443.104 211.081 476.869 266.041 476.869C335.239 476.869 391.533 422.824 391.533 356.397C391.533 350.9550000000001 395.866 346.503 401.3090000000001 346.363C402.545 346.337 403.758 346.343 404.9940000000001 346.349L406.593 346.36C453.642 346.36 491.926 308.081 491.926 261.027C491.926 215.572 456.404 178.141 411.543 175.818z" />
<glyph glyph-name="11n"
unicode="&#xEA0E;"
horiz-adv-x="512" d=" M411.247 366.3300000000001C405.951 439.213 342.843 496.943 266.038 496.943C205.107 496.943 151.411 460.914 129.988 406.511C128.499 406.565 126.998 406.5900000000001 125.489 406.5900000000001C56.295 406.593 0 350.299 0 281.101C0 211.903 56.295 155.609 125.492 155.609L143.733 155.609L141.236 149.226C140.031 146.138 140.422 142.652 142.295 139.907C144.167 137.168 147.275 135.529 150.589 135.529L250.981 135.529L250.981 25.097C250.981 20.401 254.236 16.332 258.814 15.302C259.549 15.136 260.295 15.057 261.019 15.057C264.8330000000001 15.057 268.401 17.233 270.087 20.792L334.27 156.113C335.092 155.896 335.892 155.606 336.7850000000001 155.606C361.735 155.606 411.599 155.733 412.089 155.75C468.107 158.633 512 204.873 512 261.019C512.003 317.586 467.221 363.886 411.247 366.3300000000001zM271.058 69.693L271.058 145.572C271.061 151.115 266.5710000000001 155.612 261.021 155.612L165.294 155.612L230.902 323.27L230.902 215.603C230.902 210.059 235.392 205.563 240.942 205.563L335.501 205.563L271.058 69.693zM411.543 175.818C411.543 175.818 369.739 175.711 343.556 175.691L360.443 211.295C361.924 214.409 361.698 218.055 359.854 220.966C358.021 223.875 354.815 225.637 351.374 225.637L250.981 225.637L250.981 376.476C250.981 381.305 247.549 385.4460000000001 242.805 386.339C238.059 387.24 233.354 384.627 231.589 380.134L151.591 175.694L127.119 175.703L125.492 175.694C67.365 175.694 20.08 222.982 20.08 281.107C20.08 339.231 67.365 386.519 125.492 386.519C128.992 386.519 132.463 386.347 135.884 386.015C140.501 385.534 144.924 388.389 146.394 392.857C162.991 443.104 211.081 476.869 266.041 476.869C335.239 476.869 391.533 422.824 391.533 356.397C391.533 350.9550000000001 395.866 346.503 401.3090000000001 346.363C402.545 346.337 403.758 346.343 404.9940000000001 346.349L406.593 346.36C453.642 346.36 491.926 308.081 491.926 261.027C491.926 215.572 456.404 178.141 411.543 175.818z" />
<glyph glyph-name="1232n"
unicode="&#xEA0F;"
horiz-adv-x="512" d=" M467.4 213C459.6 221.1 450.8 227.9 441.2 233.4C443.4 235.3 445.5 237.3 447.6 239.4C470.8 262.6 485.1 291.4 490.4 321.5C493.1 336.6 474.7 346.1 463.8 335.3C445.6 317.2 420.3 306.2 392.5 306.8C340.2 307.9 297.5 350.5 296.4 402.9C296 430.7 307 456 325.1 474.2C335.9 485.1 326.4 503.4 311.3 500.8C281.2 495.5 252.4 481.2 229.2 458C201.4 430.2 186.4 394.4 184.2 358C140.9 336.6 108.9 295.3 100.6 245.7C48.4 232.5 10.9 185.5 10.9 130.2C10.8999999999999 64.5 64.3 11 130 11H381.7C447.3999999999999 11 500.8 64.5 500.8 130.2C500.9 161.2 488.9999999999999 190.7 467.4 213zM262.6 432.7C260.7 423.8 259.7 414.5 259.7 405C259.7 330.4 320.1 270 394.7 270C404.2 270 413.5 271 422.4 272.9C401.7 250 371.7 235.6 338.4 235.6C275.9 235.6 225.2 286.3 225.2 348.8C225.2 382 239.6 411.9 262.6 432.7000000000001zM381.7 52.1H130C86.9999999999999 52.1 51.9 87.1 51.9 130.1999999999999C51.9 170.0999999999999 81.8 203.5 121.4 207.8L138.5999999999999 209.6999999999999L139.6999999999999 227C141.8999999999999 262.7 160.5999999999999 293.7 188.0999999999999 312.7C194.5 285.8999999999999 208.1999999999999 260.3999999999999 229.1 239.5C274.8999999999999 193.6999999999999 342.3 182.6999999999999 398.5 206.3999999999999C433.8 198.6999999999999 459.6999999999999 167.5999999999999 459.6999999999999 130.1999999999999C459.8 87.0999999999999 424.8 52.0999999999999 381.7 52.0999999999999z" />
<glyph glyph-name="13d"
unicode="&#xEA10;"
horiz-adv-x="512" d=" M426.315 326.183C415.245 409.819 361.61 470.955 276.757 470.955C205.714 470.955 166.387 428.141 142.143 361.692C133.286 363.851 124.181 364.93 114.993 364.93C51.532 364.929 0 309.661 0 246.229C0 185.536 49.678 141.504 110.703 137.851L124.541 137.851L124.541 165.527L110.703 165.527C66.643 168.156 27.677 202.141 27.677 246.229C27.677 292.337 68.885 333.517 114.993 333.517C125.786 333.517 136.387 331.415 146.488 327.3450000000001L163.176 320.5370000000001L167.687 337.9460000000001C183.296 398.666 214.099 442.863 276.757 442.863C350.346 442.863 392.939 383.443 396.233 309.882L396.953 293.913L416.05 295.2970000000001C451.64 295.2970000000001 484.353 267.732 484.353 232.197C484.353 196.633 450.754 165.553 415.163 165.553L373.65 165.553L373.65 137.878L415.135 137.878C468.051 137.878 512 179.336 512 232.197C511.999 281.128 474.637 321.0080000000001 426.315 326.183zM334.848 216.921L321.121 208.59L322.366 222.705L300.668 224.836L297.928 194.558L260.012 171.588L260.012 217.53L283.62 235.159L271 253.84L260.012 245.62L260.012 262.28L238.149 262.28L238.149 245.62L227.19 253.84L214.542 235.132L238.177 217.503L238.177 171.534L200.262 194.504L197.522 224.782L175.824 222.651L177.069 208.537L163.342 216.867L152.355 197.078L166.11 188.748L153.85 182.853L162.955 162.014L189.302 174.689L227.218 151.691L189.302 128.692L162.955 141.368L153.85 120.528L166.11 114.633L152.355 106.303L163.314 86.432L177.041 94.762L175.796 80.648L197.493 78.516L200.234 108.794L238.149 131.792L238.149 85.824L214.514 68.194L227.162 49.486L238.121 57.678L238.121 41.045L259.984 41.045L259.984 57.678L270.944 49.486L283.592 68.194L259.984 85.824L259.984 131.792L297.9 108.794L300.64 78.516L322.337 80.648L321.093 94.762L334.82 86.432L345.724 106.303L331.997 114.606L344.257 120.501L335.152 141.341L308.777 128.665L270.862 151.664L308.777 174.662L335.152 161.987L344.257 182.826L331.969 188.722L345.724 197.052L334.848 216.921z" />
<glyph glyph-name="13n"
unicode="&#xEA11;"
horiz-adv-x="512" d=" M426.315 326.183C415.245 409.819 361.61 470.955 276.757 470.955C205.714 470.955 166.387 428.141 142.143 361.692C133.286 363.851 124.181 364.93 114.993 364.93C51.532 364.929 0 309.661 0 246.229C0 185.536 49.678 141.504 110.703 137.851L124.541 137.851L124.541 165.527L110.703 165.527C66.643 168.156 27.677 202.141 27.677 246.229C27.677 292.337 68.885 333.517 114.993 333.517C125.786 333.517 136.387 331.415 146.488 327.3450000000001L163.176 320.5370000000001L167.687 337.9460000000001C183.296 398.666 214.099 442.863 276.757 442.863C350.346 442.863 392.939 383.443 396.233 309.882L396.953 293.913L416.05 295.2970000000001C451.64 295.2970000000001 484.353 267.732 484.353 232.197C484.353 196.633 450.754 165.553 415.163 165.553L373.65 165.553L373.65 137.878L415.135 137.878C468.051 137.878 512 179.336 512 232.197C511.999 281.128 474.637 321.0080000000001 426.315 326.183zM334.848 216.921L321.121 208.59L322.366 222.705L300.668 224.836L297.928 194.558L260.012 171.588L260.012 217.53L283.62 235.159L271 253.84L260.012 245.62L260.012 262.28L238.149 262.28L238.149 245.62L227.19 253.84L214.542 235.132L238.177 217.503L238.177 171.534L200.262 194.504L197.522 224.782L175.824 222.651L177.069 208.537L163.342 216.867L152.355 197.078L166.11 188.748L153.85 182.853L162.955 162.014L189.302 174.689L227.218 151.691L189.302 128.692L162.955 141.368L153.85 120.528L166.11 114.633L152.355 106.303L163.314 86.432L177.041 94.762L175.796 80.648L197.493 78.516L200.234 108.794L238.149 131.792L238.149 85.824L214.514 68.194L227.162 49.486L238.121 57.678L238.121 41.045L259.984 41.045L259.984 57.678L270.944 49.486L283.592 68.194L259.984 85.824L259.984 131.792L297.9 108.794L300.64 78.516L322.337 80.648L321.093 94.762L334.82 86.432L345.724 106.303L331.997 114.606L344.257 120.501L335.152 141.341L308.777 128.665L270.862 151.664L308.777 174.662L335.152 161.987L344.257 182.826L331.969 188.722L345.724 197.052L334.848 216.921z" />
<glyph glyph-name="50d"
unicode="&#xEA12;"
horiz-adv-x="512" d=" M0 158.202H477.483V123.685H0V158.202z M0 388.315H477.483V353.798H0V388.315z M0 273.2580000000001H477.483V238.741H0V273.258z M34.517 330.7870000000001H126.562V296.27H34.517V330.7870000000001z M161.079 330.7870000000001H512V296.27H161.079V330.7870000000001z M419.955 215.73H512V181.213H419.955V215.73z M34.517 215.73H385.438V181.213H34.517V215.73z" />
<glyph glyph-name="50n"
unicode="&#xEA13;"
horiz-adv-x="512" d=" M0 158.202H477.483V123.685H0V158.202z M0 388.315H477.483V353.798H0V388.315z M0 273.2580000000001H477.483V238.741H0V273.258z M34.517 330.7870000000001H126.562V296.27H34.517V330.7870000000001z M161.079 330.7870000000001H512V296.27H161.079V330.7870000000001z M419.955 215.73H512V181.213H419.955V215.73z M34.517 215.73H385.438V181.213H34.517V215.73z" />
</font>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -24,7 +24,9 @@ require('./services/config-validator'); // Include and kicks off the config file
const statusCheck = require('./services/status-check'); // Used by the status check feature, uses GET
const saveConfig = require('./services/save-config'); // Saves users new conf.yml to file-system
const rebuild = require('./services/rebuild-app'); // A script to programmatically trigger a build
const sslServer = require('./services/ssl-server');
const systemInfo = require('./services/system-info'); // Basic system info, for resource widget
const sslServer = require('./services/ssl-server'); // TLS-enabled web server
const corsProxy = require('./services/cors-proxy'); // Enables API requests to CORS-blocked services
/* Helper functions, and default config */
const printMessage = require('./services/print-message'); // Function to print welcome msg on start
@ -91,6 +93,24 @@ const app = express()
}).catch((response) => {
res.end(JSON.stringify(response));
});
})
// GET endpoint to return system info, for widget
.use(ENDPOINTS.systemInfo, (req, res) => {
try {
const results = systemInfo();
systemInfo.success = true;
res.end(JSON.stringify(results));
} catch (e) {
res.end(JSON.stringify({ success: false, message: e }));
}
})
// GET for accessing non-CORS API services
.use(ENDPOINTS.corsProxy, (req, res) => {
try {
corsProxy(req, res);
} catch (e) {
res.end(JSON.stringify({ success: false, message: e }));
}
});
/* Create HTTP server from app on port, and print welcome message */

47
services/cors-proxy.js Normal file
View File

@ -0,0 +1,47 @@
/**
* A simple CORS proxy, for accessing API services which aren't CORS-enabled.
* Receives requests from frontend, applies correct access control headers,
* makes request to endpoint, then responds to the frontend with the response
*/
const axios = require('axios');
module.exports = (req, res) => {
// Apply allow-all response headers
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, PUT, PATCH, POST, DELETE');
if (req.header('access-control-request-headers')) {
res.header('Access-Control-Allow-Headers', req.header('access-control-request-headers'));
}
// Pre-flight
if (req.method === 'OPTIONS') {
res.send();
return;
}
// Get desired URL, from Target-URL header
const targetURL = req.header('Target-URL');
if (!targetURL) {
res.status(500).send({ error: 'There is no Target-Endpoint header in the request' });
return;
}
// Apply any custom headers, if needed
const headers = req.header('CustomHeaders') ? JSON.parse(req.header('CustomHeaders')) : {};
// Prepare the request
const requestConfig = {
method: req.method,
url: targetURL,
json: req.body,
headers,
};
// Make the request, and respond with result
axios.request(requestConfig)
.then((response) => {
res.status(200).send(response.data);
}).catch((error) => {
res.status(500).send({ error });
});
};

View File

@ -0,0 +1,48 @@
/* A Netlify cloud function to handle requests to CORS-disabled services */
const axios = require('axios');
exports.handler = (event, context, callback) => {
// Get input data
const { body, headers, queryStringParameters } = event;
// Get URL from header or GET param
const requestUrl = queryStringParameters.url || headers['Target-URL'] || headers['target-url'];
const returnError = (msg, error) => {
callback(null, {
statusCode: 400,
body: JSON.stringify({ success: false, msg, error }),
});
};
// If URL missing, return error
if (!requestUrl) {
returnError('Missing Target-URL header', null);
}
let custom = {};
try {
custom = JSON.parse(headers.CustomHeaders || headers.customheaders || '{}');
} catch (e) { returnError('Unable to parse custom headers'); }
// Response headers
const requestHeaders = {
'Access-Control-Allow-Origin': '*',
...custom,
};
// Prepare request
const requestConfig = {
method: 'GET',
url: requestUrl,
json: body,
headers: requestHeaders,
};
// Make request
axios.request(requestConfig)
.then((response) => {
callback(null, { statusCode: 200, body: JSON.stringify(response.data) });
}).catch((error) => {
returnError('Request failed', error);
});
};

24
services/system-info.js Normal file
View File

@ -0,0 +1,24 @@
/**
* Gets basic system info, for the resource usage widget
*/
const os = require('os');
module.exports = () => {
const meta = {
timestamp: new Date(),
uptime: os.uptime(),
hostname: os.hostname(),
username: os.userInfo().username,
system: `${os.version()} (${os.platform()})`,
};
const memory = {
total: `${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB`,
freePercent: (os.freemem() / os.totalmem()).toFixed(2),
};
const loadAv = os.loadavg();
const load = { one: loadAv[0], five: loadAv[1], fifteen: loadAv[2] };
return { meta, memory, load };
};

View File

@ -19,7 +19,6 @@ import Keys from '@/utils/StoreMutations';
import {
localStorageKeys,
splashScreenTime,
visibleComponents as defaultVisibleComponents,
language as defaultLanguage,
} from '@/utils/defaults';
@ -36,6 +35,12 @@ export default {
isLoading: true, // Set to false after mount complete
};
},
watch: {
isEditMode(isEditMode) {
// When in edit mode, show confirmation dialog on page exit
window.onbeforeunload = isEditMode ? this.confirmExit : null;
},
},
computed: {
/* If the user has specified custom text for footer - get it */
footerText() {
@ -43,7 +48,7 @@ export default {
},
/* Determine if splash screen should be shown */
shouldShowSplash() {
return (this.visibleComponents || defaultVisibleComponents).splashScreen;
return (this.appConfig.showSplashScreen);
},
config() {
return this.$store.state.config;
@ -77,7 +82,7 @@ export default {
/* Hide splash screen, either after 2 seconds, or immediately based on user preference */
hideSplash() {
if (this.shouldShowSplash) {
setTimeout(() => { this.isLoading = false; }, splashScreenTime || 1500);
setTimeout(() => { this.isLoading = false; }, splashScreenTime || 1000);
} else {
this.isLoading = false;
}
@ -118,21 +123,27 @@ export default {
this.$i18n.locale = language;
document.getElementsByTagName('html')[0].setAttribute('lang', language);
},
/* If placeholder element still visible, hide it */
hideLoader() {
const loader = document.getElementById('loader');
if (loader) loader.style.display = 'none';
},
/* Called when in edit mode and navigating away from page */
confirmExit(e) {
e.preventDefault();
return 'You may have unsaved edits. Are you sure you want to exit the page?';
},
},
/* When component mounted, hide splash and initiate the injection of custom styles */
/* Basic initialization tasks on app load */
mounted() {
this.applyLanguage();
this.hideSplash();
if (this.appConfig.customCss) {
this.applyLanguage(); // Apply users local language
this.hideSplash(); // Hide the splash screen, if visible
if (this.appConfig.customCss) { // Inject users custom CSS, if present
const cleanedCss = this.appConfig.customCss.replace(/<\/?[^>]+(>|$)/g, '');
this.injectCustomStyles(cleanedCss);
this.hideLoader();
}
welcomeMsg();
this.hideLoader(); // If initial placeholder still visible, hide it
welcomeMsg(); // Show message in console
},
};
</script>

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="paste" class="svg-inline--fa fa-paste fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M433.941 193.941l-51.882-51.882A48 48 0 0 0 348.118 128H320V80c0-26.51-21.49-48-48-48h-61.414C201.582 13.098 182.294 0 160 0s-41.582 13.098-50.586 32H48C21.49 32 0 53.49 0 80v288c0 26.51 21.49 48 48 48h80v48c0 26.51 21.49 48 48 48h224c26.51 0 48-21.49 48-48V227.882a48 48 0 0 0-14.059-33.941zm-84.066-16.184l48.368 48.368a6 6 0 0 1 1.757 4.243V240h-64v-64h9.632a6 6 0 0 1 4.243 1.757zM160 38c9.941 0 18 8.059 18 18s-8.059 18-18 18-18-8.059-18-18 8.059-18 18-18zm-32 138v192H54a6 6 0 0 1-6-6V86a6 6 0 0 1 6-6h55.414c9.004 18.902 28.292 32 50.586 32s41.582-13.098 50.586-32H266a6 6 0 0 1 6 6v42h-96c-26.51 0-48 21.49-48 48zm266 288H182a6 6 0 0 1-6-6V182a6 6 0 0 1 6-6h106v88c0 13.255 10.745 24 24 24h88v170a6 6 0 0 1-6 6z"></path></svg>

After

Width:  |  Height:  |  Size: 949 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="sync" class="svg-inline--fa fa-sync fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.575C418.212 52.043 342.256 8 256 8 134.813 8 33.933 94.924 12.296 209.824 10.908 217.193 16.604 224 24.103 224h28.576c5.674 0 10.542-3.982 11.737-9.529C83.441 126.128 161.917 60 256 60c79.545 0 147.942 47.282 178.676 115.302l-126.39-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12zm-12.103 280h-28.576c-5.674 0-10.542 3.982-11.737 9.529C428.559 385.872 350.083 452 256 452c-79.546 0-147.942-47.282-178.676-115.302l126.39 3.009c6.737.16 12.286-5.257 12.286-11.997V300c0-6.627-5.373-12-12-12H12c-6.627 0-12 5.373-12 12v192c0 6.627 5.373 12 12 12h27.711c6.739 0 12.157-5.548 11.997-12.286l-2.347-98.575C93.788 459.957 169.744 504 256 504c121.187 0 222.067-86.924 243.704-201.824 1.388-7.369-4.308-14.176-11.807-14.176z"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,248 +1,295 @@
{
"home": {
"no-results": "No Search Results",
"no-data": "No Data Configured"
},
"search": {
"search-label": "Search",
"search-placeholder": "Start typing to filter",
"clear-search-tooltip": "Clear Search",
"enter-to-search-web": "Press enter to search the web"
},
"login": {
"title": "Dashy",
"username-label": "Username",
"password-label": "Password",
"login-button": "Login",
"remember-me-label": "Remember me for",
"remember-me-never": "Never",
"remember-me-hour": "4 Hours",
"remember-me-day": "1 Day",
"remember-me-week": "1 Week",
"remember-me-long-time": "A long time",
"error-missing-username": "Missing Username",
"error-missing-password": "Missing Password",
"error-incorrect-username": "User not found",
"error-incorrect-password": "Incorrect Password",
"success-message": "Logging in...",
"logout-message": "Logged Out",
"already-logged-in-title": "Already Logged In",
"already-logged-in-text": "You're logged in as",
"proceed-to-dashboard": "Proceed to Dashboard",
"log-out-button": "Logout",
"proceed-guest-button": "Proceed as Guest"
},
"config": {
"main-tab": "Main Menu",
"view-config-tab": "View Config",
"edit-config-tab": "Edit Config",
"custom-css-tab": "Custom Styles",
"heading": "Configuration Options",
"download-config-button": "View / Export Config",
"edit-config-button": "Edit Config",
"edit-css-button": "Edit Custom CSS",
"cloud-sync-button": "Enable Cloud Sync",
"edit-cloud-sync-button": "Edit Cloud Sync",
"rebuild-app-button": "Rebuild Application",
"change-language-button": "Change App Language",
"reset-settings-button": "Reset Local Settings",
"app-info-button": "App Info",
"backup-note": "It is recommend to make a backup of your configuration before making changes.",
"reset-config-msg-l1": "This will remove all user settings from local storage, but won't effect your 'conf.yml' file.",
"reset-config-msg-l2": "You should first backup any changes you've made locally, if you want to use them in the future.",
"reset-config-msg-l3": "Are you sure you want to proceed?",
"data-cleared-msg": "Data cleared successfully",
"actions-label": "Actions",
"copy-config-label": "Copy Config",
"data-copied-msg": "Config has been copied to clipboard",
"reset-config-label": "Reset Config",
"css-save-btn": "Save Changes",
"css-note-label": "Note",
"css-note-l1": "You will need to refresh the page for your changes to take effect.",
"css-note-l2": "Styles overrides are only stored locally, so it is recommended to make a copy of your CSS.",
"css-note-l3": "To remove all custom styles, delete the contents and hit Save Changes"
},
"alternate-views": {
"alternate-view-heading": "Switch View",
"default": "Default",
"workspace": "Workspace",
"minimal": "Minimal"
},
"settings": {
"theme-label": "Theme",
"layout-label": "Layout",
"layout-auto": "Auto",
"layout-horizontal": "Horizontal",
"layout-vertical": "Vertical",
"item-size-label": "Item Size",
"item-size-small": "Small",
"item-size-medium": "Medium",
"item-size-large": "Large",
"config-launcher-label": "Config",
"config-launcher-tooltip": "Update Configuration",
"sign-out-tooltip": "Sign Out",
"sign-in-tooltip": "Log In",
"sign-in-welcome": "Hello {username}!"
},
"updates": {
"app-version-note": "Dashy version",
"up-to-date": "Up-to-Date",
"out-of-date": "Update Available",
"unsupported-version-l1": "You are using an unsupported version of Dashy",
"unsupported-version-l2": "For the best experience, and recent security patches, please update to"
},
"language-switcher": {
"title": "Change Application Language",
"dropdown-label": "Select a Language",
"save-button": "Save",
"success-msg": "Language Updated to"
},
"theme-maker": {
"title": "Theme Configurator",
"export-button": "Export Custom Variables",
"reset-button": "Reset Styles for",
"show-all-button": "Show All Variables",
"change-fonts-button": "Change Fonts",
"save-button": "Save",
"cancel-button": "Cancel",
"saved-toast": "{theme} Updated Successfully",
"copied-toast": "Theme data for {theme} copied to clipboard",
"reset-toast": "Custom Colors for {theme} Removed"
},
"config-editor": {
"save-location-label": "Save Location",
"location-local-label": "Apply Locally",
"location-disk-label": "Write Changes to Config File",
"save-button": "Save Changes",
"preview-button": "Preview Changes",
"valid-label": "Config is Valid",
"status-success-msg": "Task Complete",
"status-fail-msg": "Task Failed",
"success-msg-disk": "Config file written to disk successfully",
"success-msg-local": "Local changes saved successfully",
"success-note-l1": "The app should rebuild automatically.",
"success-note-l2": "This may take up to a minute.",
"success-note-l3": "You will need to refresh the page for changes to take effect.",
"error-msg-save-mode": "Please select a Save Mode: Local or File",
"error-msg-cannot-save": "An error occurred saving config",
"error-msg-bad-json": "Error in JSON, possibly malformed",
"warning-msg-validation": "Validation Warning",
"not-admin-note": "You cannot write changed to disk, because you are not logged in as an admin"
},
"app-rebuild": {
"title": "Rebuild Application",
"rebuild-note-l1": "A rebuild is required for changes written to the conf.yml file to take effect.",
"rebuild-note-l2": "This should happen automatically, but if it hasn't, you can manually trigger it here.",
"rebuild-note-l3": "This is not required for modifications stored locally.",
"rebuild-button": "Start Build",
"rebuilding-status-1": "Building...",
"rebuilding-status-2": "This may take a few minutes",
"error-permission": "You don't have permission to trigger this action",
"success-msg": "Build completed successfully",
"fail-msg": "Build operation failed",
"reload-note": "A page reload is now required for changes to take effect",
"reload-button": "Reload Page"
},
"cloud-sync": {
"title": "Cloud Backup & Restore",
"intro-l1": "Cloud backup and restore is an optional feature, that enables you to upload your config to the internet, and then restore it on any other device or instance of Dashy.",
"intro-l2": "All data is fully end-to-end encrypted with AES, using your password as the key.",
"intro-l3": "For more info, please see the",
"backup-title-setup": "Make a Backup",
"backup-title-update": "Update Backup",
"password-label-setup": "Choose a Password",
"password-label-update": "Enter your Password",
"backup-button-setup": "Backup",
"backup-button-update": "Update Backup",
"backup-id-label": "Your Backup ID",
"backup-id-note": "This is used to restore from backups later. So keep it, along with your password somewhere safe.",
"restore-title": "Restore a Backup",
"restore-id-label": "Restore ID",
"restore-password-label": "Password",
"restore-button": "Restore",
"backup-missing-password": "Missing Password",
"backup-error-unknown": "Unable to process request",
"backup-error-password": "Incorrect password. Please enter your current password.",
"backup-success-msg": "Completed Successfully",
"restore-success-msg": "Config Restored Successfully"
},
"menu": {
"open-section-title": "Open In",
"sametab": "Current Tab",
"newtab": "New Tab",
"modal": "Pop-Up Modal",
"workspace": "Workspace View",
"options-section-title": "Options",
"edit-item": "Edit",
"move-item": "Copy or Move",
"remove-item": "Remove"
},
"context-menus": {
"item": {
"open-section-title": "Open In",
"sametab": "Current Tab",
"newtab": "New Tab",
"modal": "Pop-Up Modal",
"workspace": "Workspace View",
"options-section-title": "Options",
"edit-item": "Edit",
"move-item": "Copy or Move",
"remove-item": "Remove"
},
"section": {
"open-section": "Open Section",
"edit-section": "Edit",
"move-section": "Move To",
"remove-section": "Remove"
}
},
"interactive-editor": {
"menu": {
"start-editing-tooltip": "Enter the Interactive Editor",
"edit-site-data-subheading": "Edit Site Data",
"edit-page-info-btn": "Edit Page Info",
"edit-page-info-tooltip": "App title, description, nav links, footer text, etc",
"edit-app-config-btn": "Edit App Config",
"edit-app-config-tooltip": "All other app configuration options",
"config-save-methods-subheading": "Config Saving Options",
"save-locally-btn": "Save Locally",
"save-locally-tooltip": "Save config locally, to browser storage. This will not affect your config file, but changes will only be saved on this device",
"save-disk-btn": "Save to Disk",
"save-disk-tooltip": "Save config to the conf.yml file on disk. This will backup, and then over-write your existing config",
"export-config-btn": "Export Config",
"export-config-tooltip": "View and export new config, either to a file, or to clipboard",
"cloud-backup-btn": "Backup to Cloud",
"cloud-backup-tooltip": "Save encrypted backup of configuration to cloud",
"edit-raw-config-btn": "Edit Raw Config",
"edit-raw-config-tooltip": "View and modify raw config via JSON editor",
"cancel-changes-btn": "Cancel Edit",
"cancel-changes-tooltip": "Reset current modifications, and exit Edit Mode. This will not affect your saved config",
"edit-mode-name": "Edit Mode",
"edit-mode-subtitle": "You are in Edit Mode",
"edit-mode-description": "This means you can make modifications to your config, and preview the results, but until you save, none of your changes will be preserved.",
"save-stage-btn": "Save",
"cancel-stage-btn": "Cancel"
},
"edit-section": {
"edit-section-title": "Edit Section",
"add-section-title": "Add New Section",
"edit-tooltip": "Click to Edit, or right-click for more options",
"remove-confirm": "Are you sure you want to remove this section? This action can be undone later."
},
"edit-app-config": {
"warning-msg-title": "Proceed with Caution",
"warning-msg-l1": "The following options are for advanced app configuration.",
"warning-msg-l2": "If you are unsure about any of the fields, please reference the",
"warning-msg-docs": "documentation",
"warning-msg-l3": "to avoid unintended consequences."
},
"export": {
"export-title": "Export Config",
"copy-clipboard-btn": "Copy to Clipboard",
"copy-clipboard-tooltip": "Copy all app config to system clipboard, in YAML format",
"download-file-btn": "Download as File",
"download-file-tooltip": "Download all app config to your device, in a YAML file",
"view-title": "View Config"
}
}
{
"home": {
"no-results": "No Search Results",
"no-data": "No Data Configured",
"no-items-section": "No Items to Show Yet"
},
"search": {
"search-label": "Search",
"search-placeholder": "Start typing to filter",
"clear-search-tooltip": "Clear Search",
"enter-to-search-web": "Press enter to search the web"
},
"login": {
"title": "Dashy",
"username-label": "Username",
"password-label": "Password",
"login-button": "Login",
"remember-me-label": "Remember me for",
"remember-me-never": "Never",
"remember-me-hour": "4 Hours",
"remember-me-day": "1 Day",
"remember-me-week": "1 Week",
"remember-me-long-time": "A long time",
"error-missing-username": "Missing Username",
"error-missing-password": "Missing Password",
"error-incorrect-username": "User not found",
"error-incorrect-password": "Incorrect Password",
"success-message": "Logging in...",
"logout-message": "Logged Out",
"already-logged-in-title": "Already Logged In",
"already-logged-in-text": "You're logged in as",
"proceed-to-dashboard": "Proceed to Dashboard",
"log-out-button": "Logout",
"proceed-guest-button": "Proceed as Guest"
},
"config": {
"main-tab": "Main Menu",
"view-config-tab": "View Config",
"edit-config-tab": "Edit Config",
"custom-css-tab": "Custom Styles",
"heading": "Configuration Options",
"download-config-button": "View / Export Config",
"edit-config-button": "Edit Config",
"edit-css-button": "Edit Custom CSS",
"cloud-sync-button": "Enable Cloud Sync",
"edit-cloud-sync-button": "Edit Cloud Sync",
"rebuild-app-button": "Rebuild Application",
"change-language-button": "Change App Language",
"reset-settings-button": "Reset Local Settings",
"app-info-button": "App Info",
"backup-note": "It is recommend to make a backup of your configuration before making changes.",
"reset-config-msg-l1": "This will remove all user settings from local storage, but won't effect your 'conf.yml' file.",
"reset-config-msg-l2": "You should first backup any changes you've made locally, if you want to use them in the future.",
"reset-config-msg-l3": "Are you sure you want to proceed?",
"data-cleared-msg": "Data cleared successfully",
"actions-label": "Actions",
"copy-config-label": "Copy Config",
"data-copied-msg": "Config has been copied to clipboard",
"reset-config-label": "Reset Config",
"css-save-btn": "Save Changes",
"css-note-label": "Note",
"css-note-l1": "You will need to refresh the page for your changes to take effect.",
"css-note-l2": "Styles overrides are only stored locally, so it is recommended to make a copy of your CSS.",
"css-note-l3": "To remove all custom styles, delete the contents and hit Save Changes"
},
"alternate-views": {
"alternate-view-heading": "Switch View",
"default": "Default",
"workspace": "Workspace",
"minimal": "Minimal"
},
"settings": {
"theme-label": "Theme",
"layout-label": "Layout",
"layout-auto": "Auto",
"layout-horizontal": "Horizontal",
"layout-vertical": "Vertical",
"item-size-label": "Item Size",
"item-size-small": "Small",
"item-size-medium": "Medium",
"item-size-large": "Large",
"config-launcher-label": "Config",
"config-launcher-tooltip": "Update Configuration",
"sign-out-tooltip": "Sign Out",
"sign-in-tooltip": "Log In",
"sign-in-welcome": "Hello {username}!"
},
"updates": {
"app-version-note": "Dashy version",
"up-to-date": "Up-to-Date",
"out-of-date": "Update Available",
"unsupported-version-l1": "You are using an unsupported version of Dashy",
"unsupported-version-l2": "For the best experience, and recent security patches, please update to"
},
"language-switcher": {
"title": "Change Application Language",
"dropdown-label": "Select a Language",
"save-button": "Save",
"success-msg": "Language Updated to"
},
"theme-maker": {
"title": "Theme Configurator",
"export-button": "Export Custom Variables",
"reset-button": "Reset Styles for",
"show-all-button": "Show All Variables",
"change-fonts-button": "Change Fonts",
"save-button": "Save",
"cancel-button": "Cancel",
"saved-toast": "{theme} Updated Successfully",
"copied-toast": "Theme data for {theme} copied to clipboard",
"reset-toast": "Custom Colors for {theme} Removed"
},
"config-editor": {
"save-location-label": "Save Location",
"location-local-label": "Apply Locally",
"location-disk-label": "Write Changes to Config File",
"save-button": "Save Changes",
"preview-button": "Preview Changes",
"valid-label": "Config is Valid",
"status-success-msg": "Task Complete",
"status-fail-msg": "Task Failed",
"success-msg-disk": "Config file written to disk successfully",
"success-msg-local": "Local changes saved successfully",
"success-note-l1": "The app should rebuild automatically.",
"success-note-l2": "This may take up to a minute.",
"success-note-l3": "You will need to refresh the page for changes to take effect.",
"error-msg-save-mode": "Please select a Save Mode: Local or File",
"error-msg-cannot-save": "An error occurred saving config",
"error-msg-bad-json": "Error in JSON, possibly malformed",
"warning-msg-validation": "Validation Warning",
"not-admin-note": "You cannot write changed to disk, because you are not logged in as an admin"
},
"app-rebuild": {
"title": "Rebuild Application",
"rebuild-note-l1": "A rebuild is required for changes written to the conf.yml file to take effect.",
"rebuild-note-l2": "This should happen automatically, but if it hasn't, you can manually trigger it here.",
"rebuild-note-l3": "This is not required for modifications stored locally.",
"rebuild-button": "Start Build",
"rebuilding-status-1": "Building...",
"rebuilding-status-2": "This may take a few minutes",
"error-permission": "You don't have permission to trigger this action",
"success-msg": "Build completed successfully",
"fail-msg": "Build operation failed",
"reload-note": "A page reload is now required for changes to take effect",
"reload-button": "Reload Page"
},
"cloud-sync": {
"title": "Cloud Backup & Restore",
"intro-l1": "Cloud backup and restore is an optional feature, that enables you to upload your config to the internet, and then restore it on any other device or instance of Dashy.",
"intro-l2": "All data is fully end-to-end encrypted with AES, using your password as the key.",
"intro-l3": "For more info, please see the",
"backup-title-setup": "Make a Backup",
"backup-title-update": "Update Backup",
"password-label-setup": "Choose a Password",
"password-label-update": "Enter your Password",
"backup-button-setup": "Backup",
"backup-button-update": "Update Backup",
"backup-id-label": "Your Backup ID",
"backup-id-note": "This is used to restore from backups later. So keep it, along with your password somewhere safe.",
"restore-title": "Restore a Backup",
"restore-id-label": "Restore ID",
"restore-password-label": "Password",
"restore-button": "Restore",
"backup-missing-password": "Missing Password",
"backup-error-unknown": "Unable to process request",
"backup-error-password": "Incorrect password. Please enter your current password.",
"backup-success-msg": "Completed Successfully",
"restore-success-msg": "Config Restored Successfully"
},
"menu": {
"open-section-title": "Open In",
"sametab": "Current Tab",
"newtab": "New Tab",
"modal": "Pop-Up Modal",
"workspace": "Workspace View",
"options-section-title": "Options",
"edit-item": "Edit",
"move-item": "Copy or Move",
"remove-item": "Remove"
},
"context-menus": {
"item": {
"open-section-title": "Open In",
"sametab": "Current Tab",
"newtab": "New Tab",
"modal": "Pop-Up Modal",
"workspace": "Workspace View",
"clipboard": "Copy to Clipboard",
"options-section-title": "Options",
"edit-item": "Edit",
"move-item": "Copy or Move",
"remove-item": "Remove",
"copied-toast": "URL has been copied to clipboard"
},
"section": {
"open-section": "Open Section",
"edit-section": "Edit",
"move-section": "Move To",
"remove-section": "Remove"
}
},
"interactive-editor": {
"menu": {
"start-editing-tooltip": "Enter the Interactive Editor",
"edit-site-data-subheading": "Edit Site Data",
"edit-page-info-btn": "Edit Page Info",
"edit-page-info-tooltip": "App title, description, nav links, footer text, etc",
"edit-app-config-btn": "Edit App Config",
"edit-app-config-tooltip": "All other app configuration options",
"config-save-methods-subheading": "Config Saving Options",
"save-locally-btn": "Save Locally",
"save-locally-tooltip": "Save config locally, to browser storage. This will not affect your config file, but changes will only be saved on this device",
"save-disk-btn": "Save to Disk",
"save-disk-tooltip": "Save config to the conf.yml file on disk. This will backup, and then over-write your existing config",
"export-config-btn": "Export Config",
"export-config-tooltip": "View and export new config, either to a file, or to clipboard",
"cloud-backup-btn": "Backup to Cloud",
"cloud-backup-tooltip": "Save encrypted backup of configuration to cloud",
"edit-raw-config-btn": "Edit Raw Config",
"edit-raw-config-tooltip": "View and modify raw config via JSON editor",
"cancel-changes-btn": "Cancel Edit",
"cancel-changes-tooltip": "Reset current modifications, and exit Edit Mode. This will not affect your saved config",
"edit-mode-name": "Edit Mode",
"edit-mode-subtitle": "You are in Edit Mode",
"edit-mode-description": "This means you can make modifications to your config, and preview the results, but until you save, none of your changes will be preserved.",
"save-stage-btn": "Save",
"cancel-stage-btn": "Cancel"
},
"edit-item": {
"missing-title-err": "An item title is required"
},
"edit-section": {
"edit-section-title": "Edit Section",
"add-section-title": "Add New Section",
"edit-tooltip": "Click to Edit, or right-click for more options",
"remove-confirm": "Are you sure you want to remove this section? This action can be undone later."
},
"edit-app-config": {
"warning-msg-title": "Proceed with Caution",
"warning-msg-l1": "The following options are for advanced app configuration.",
"warning-msg-l2": "If you are unsure about any of the fields, please reference the",
"warning-msg-docs": "documentation",
"warning-msg-l3": "to avoid unintended consequences."
},
"export": {
"export-title": "Export Config",
"copy-clipboard-btn": "Copy to Clipboard",
"copy-clipboard-tooltip": "Copy all app config to system clipboard, in YAML format",
"download-file-btn": "Download as File",
"download-file-tooltip": "Download all app config to your device, in a YAML file",
"view-title": "View Config"
}
},
"widgets": {
"general": {
"loading": "Loading...",
"show-more": "Expand Details",
"show-less": "Show Less",
"open-link": "Continue Reading"
},
"pi-hole": {
"status-heading": "Status"
},
"stat-ping": {
"up": "Online",
"down": "Offline"
},
"net-data": {
"cpu-chart-title": "CPU History",
"mem-chart-title": "Memory Usage",
"mem-breakdown-title": "Memory Breakdown",
"load-chart-title": "System Load"
},
"glances": {
"disk-space-free": "Free",
"disk-space-used": "Used",
"disk-mount-point": "Mount Point",
"disk-file-system": "File System",
"disk-io-read": "Read",
"disk-io-write": "Write",
"system-load-desc": "Number of processes waiting in the run-queue, averaged across all cores"
},
"system-info": {
"uptime": "Uptime"
},
"flight-data": {
"arrivals": "Arrivals",
"departures": "Departures"
},
"tfl-status": {
"good-service-all": "Good Service on all Lines",
"good-service-rest": "Good Service on all other Lines"
}
}
}

View File

@ -1,248 +1,284 @@
{
"home": {
"no-results": "Inga sökresultat",
"no-data": "Ingen data konfigurerad"
"home":{
"no-results":"Inga sökresultat",
"no-data":"Ingen data konfigurerad",
"no-items-section":"Inga objekt att visa än"
},
"search":{
"search-label":"Sök",
"search-placeholder":"Börja skriva för att filtrera",
"clear-search-tooltip":"Rensa sök",
"enter-to-search-web":"Tryck på Retur för att söka på webben"
},
"login":{
"title":"Dashy",
"username-label":"Användarnamn",
"password-label":"Lösenord",
"login-button":"Logga in",
"remember-me-label":"Kom ihåg mig",
"remember-me-never":"Aldrig",
"remember-me-hour":"4 Timmar",
"remember-me-day":"1 Dag",
"remember-me-week":"1 Vecka",
"remember-me-long-time":"Länge",
"error-missing-username":"Användarnamn saknas",
"error-missing-password":"Lösenord saknas",
"error-incorrect-username":"Användaren hittas inte",
"error-incorrect-password":"Fel lösenord",
"success-message":"Loggar in...",
"logout-message":"Utloggad",
"already-logged-in-title":"Redan inloggad",
"already-logged-in-text":"Du är inloggad som",
"proceed-to-dashboard":"Fortsätt till Dashboard",
"log-out-button":"Logga ut",
"proceed-guest-button":"Fortsätt som Gäst"
},
"config":{
"main-tab":"Huvudmeny",
"view-config-tab":"Visa konfiguration",
"edit-config-tab":"Redigera konfiguration",
"custom-css-tab":"Egna stilmallar",
"heading":"Konfigurationsalternativ",
"download-config-button":"Visa / Exportera konfiguration",
"edit-config-button":"Redigera konfiguration",
"edit-css-button":"Redigera Custom CSS",
"cloud-sync-button":"Aktivera molnsynk",
"edit-cloud-sync-button":"Redigera molnsynk",
"rebuild-app-button":"Återuppbygga appen",
"change-language-button":"Ändra appspråk",
"reset-settings-button":"Återställ lokala inställningar",
"app-info-button":"Appinfo",
"backup-note":"Det rekommenderas att du gör en säkerhetskopia av din konfiguration innan du gör ändringar.",
"reset-config-msg-l1":"Detta tar bort alla användarinställningar från lokal lagring, men påverkar inte din 'conf.yml'-fil",
"reset-config-msg-l2":"Du bör först göra en säkerhetskopia av alla ändringar du har gjort lokalt, om du vill använda dem i framtiden.",
"reset-config-msg-l3":"Är du säker på att du vill fortsätta?",
"data-cleared-msg":"Datarensning har lyckats",
"actions-label":"Åtgärder",
"copy-config-label":"Kopiera konfiguration",
"data-copied-msg":"Konfiguration har kopierats till urklipp",
"reset-config-label":"Återställ konfiguration",
"css-save-btn":"Spara ändringar",
"css-note-label":"Not",
"css-note-l1":"Du måste uppdatera sidan för att dina ändringar ska gälla.",
"css-note-l2":"Styles overrides lagras bara lokalt, så det rekommenderas att du gör en kopia av din CSS.",
"css-note-l3":"För att ta bort alla egna stilmallar, radera innehållet och tryck på Spara ändringar"
},
"alternate-views":{
"alternate-view-heading":"Ändra vy",
"default":"Standard",
"workspace":"Workspace",
"minimal":"Minimal"
},
"settings":{
"theme-label":"Tema",
"layout-label":"Layout",
"layout-auto":"Auto",
"layout-horizontal":"Vågrät",
"layout-vertical":"Lodrät",
"item-size-label":"Storlek",
"item-size-small":"Liten",
"item-size-medium":"Mellan",
"item-size-large":"Stor",
"config-launcher-label":"Konfig",
"config-launcher-tooltip":"Uppdatera konfiguration",
"sign-out-tooltip":"Logga ut",
"sign-in-tooltip":"Logga in",
"sign-in-welcome":"Hej {username}!"
},
"updates":{
"app-version-note":"Dashy-version",
"up-to-date":"Uppdaterat",
"out-of-date":"Uppdatering finns",
"unsupported-version-l1":"Du använder en icke-stödd version av Dashy",
"unsupported-version-l2":"För den bästa upplevelsen och de senaste säkerhetskorrigeringarna, uppdatera till"
},
"language-switcher":{
"title":"Ändra appspråk",
"dropdown-label":"Välj språk",
"save-button":"Spara",
"success-msg":"Språket har ändrats till"
},
"theme-maker":{
"title":"Temakonfigurator",
"export-button":"Exportera egendefinierade variabler",
"reset-button":"Återställ stilmallar för",
"show-all-button":"Vissa alla variabler",
"change-fonts-button":"Ändra typsnitt",
"save-button":"Spara",
"cancel-button":"Avbryt",
"saved-toast":"{theme} har uppdaterats",
"copied-toast":"Temadatan för {theme} har kopierats till urklipp",
"reset-toast":"Egna färger för {theme} har tagits bort"
},
"config-editor":{
"save-location-label":"Sparningsplats",
"location-local-label":"Tillämpa lokalt",
"location-disk-label":"Skriv ändringar till konfigurationsfil",
"save-button":"Spara ändringar",
"preview-button":"Förhandsgranska ändringar",
"valid-label":"Konfigurationen är giltig",
"status-success-msg":"Åtgärden slutförts",
"status-fail-msg":"Åtgärden misslyckats",
"success-msg-disk":"Konfigurationsfil har skrivits till disk utan problem",
"success-msg-local":"Lokala ändringar har sparats utan problem",
"success-note-l1":"Återskapa",
"success-note-l2":"Detta kan ta upp till en minut.",
"success-note-l3":"Du måste uppdatera sidan för att ändringar ska gälla",
"error-msg-save-mode":"Välj Lagringsläge: Lokalt eller Fil",
"error-msg-cannot-save":"Ett fel uppstod när konfigurationen skulle sparas",
"error-msg-bad-json":"Fel i JSON, möjligen felformaterat",
"warning-msg-validation":"Valideringsvarning",
"not-admin-note":"Du kan inte skriva ändringar till disk, eftersom du inte är inloggad som admin"
},
"app-rebuild":{
"title":"Återskapa appen",
"rebuild-note-l1":"Appen måste återskapas för att ändringar som skrivits till filen conf.yml ska gälla.",
"rebuild-note-l2":"Detta bör ske automatiskt, men om det inte har gjort det kan du aktivera det manuellt här.",
"rebuild-note-l3":"Detta krävs inte för ändringar som lagras lokalt.",
"rebuild-button":"Återskapa",
"rebuilding-status-1":"Återskapar...",
"rebuilding-status-2":"Detta kan ta några minuter",
"error-permission":"Du har inte behörighet att utföra denna åtgärd",
"success-msg":"Återskapning lyckats",
"fail-msg":"Återskapning misslyckats",
"reload-note":"En omladdning av sidan krävs nu för att ändringarna ska gälla",
"reload-button":"Ladda om sidan"
},
"cloud-sync":{
"title":"Molnsäkerhetskopiering och återställning",
"intro-l1":"Molnsäkerhetskopiering och återställning är en valfri funktion som gör att du kan ladda upp din konfiguration till internet och sedan återställa den på någon annan enhet eller instans av Dashy.",
"intro-l2":"All data är fullständigt end-to-end krypterad med AES, med ditt lösenord som nyckel.",
"intro-l3":"För mer information, vänligen se",
"backup-title-setup":"Gör en säkerhetskopia",
"backup-title-update":"Uppdatera säkerhetskopia",
"password-label-setup":"Välj lösenord",
"password-label-update":"Ange ditt lösenord",
"backup-button-setup":"Säkerhetskopiering",
"backup-button-update":"Uppdatera säkerhetskopia",
"backup-id-label":"Ditt säkerhetskopierings-ID",
"backup-id-note":"Detta används för att återställa från säkerhetskopior senare. Så förvara det tillsammans med ditt lösenord någonstans säkert.",
"restore-title":"Återställ en säkerhetskopia",
"restore-id-label":"Återställ ID",
"restore-password-label":"Lösenord",
"restore-button":"Återställ",
"backup-missing-password":"Lösenord saknas",
"backup-error-unknown":"Begäran kan inte behandlas",
"backup-error-password":"Fel lösenord. Vänligen ange ditt aktuella lösenord.",
"backup-success-msg":"Slutfört utan problem",
"restore-success-msg":"Konfigurationen har återställts utan problem"
},
"menu":{
"open-section-title":"Öppna i",
"sametab":"Denna flik",
"newtab":"Ny flik",
"modal":"Pop-Up Modal",
"workspace":"Workspace-vy",
"options-section-title":"Alternativ",
"edit-item":"Redigera",
"move-item":"Kopiera eller flytta",
"remove-item":"Ta bort"
},
"context-menus":{
"item":{
"open-section-title":"Öppna i",
"sametab":"Denna flik",
"newtab":"Ny flik",
"modal":"Pop-Up Modal",
"workspace":"Workspace View",
"options-section-title":"Alternativ",
"edit-item":"Redigera",
"move-item":"Kopiera eller flytta",
"remove-item":"Ta bort"
},
"search": {
"search-label": "Sök",
"search-placeholder": "Börja skriva för att filtrera",
"clear-search-tooltip": "Rensa sök",
"enter-to-search-web": "Tryck på Retur för att söka på webben"
},
"login": {
"title": "Dashy",
"username-label": "Användarnamn",
"password-label": "Lösenord",
"login-button": "Logga in",
"remember-me-label": "Kom ihåg mig",
"remember-me-never": "Aldrig",
"remember-me-hour": "4 Timmar",
"remember-me-day": "1 Dag",
"remember-me-week": "1 Vecka",
"remember-me-long-time": "Länge",
"error-missing-username": "Användarnamn saknas",
"error-missing-password": "Lösenord saknas",
"error-incorrect-username": "Användaren hittas inte",
"error-incorrect-password": "Fel lösenord",
"success-message": "Loggar in...",
"logout-message": "Utloggad",
"already-logged-in-title": "Redan inloggad",
"already-logged-in-text": "Du är inloggad som",
"proceed-to-dashboard": "Fortsätt till Dashboard",
"log-out-button": "Logga ut",
"proceed-guest-button": "Fortsätt som Gäst"
},
"config": {
"main-tab": "Huvudmeny",
"view-config-tab": "Visa konfiguration",
"edit-config-tab": "Redigera konfiguration",
"custom-css-tab": "Egendefinierade stilmallar",
"heading": "Konfigurationsalternativ",
"download-config-button": "Visa / Exportera konfiguration",
"edit-config-button": "Redigera konfiguration",
"edit-css-button": "Redigera egendefinierad CSS",
"cloud-sync-button": "Aktivera molnsynkronisering",
"edit-cloud-sync-button": "Redigera molnsynkronisering",
"rebuild-app-button": "Återuppbygga appen",
"change-language-button": "Ändra appspråk",
"reset-settings-button": "Återställ lokala inställningar",
"app-info-button": "Appinfo",
"backup-note": "Det rekommenderas att du gör en säkerhetskopia av din konfiguration innan du gör ändringar.",
"reset-config-msg-l1": "Detta tar bort alla användarinställningar från lokal lagring, men påverkar inte din 'conf.yml'-fil",
"reset-config-msg-l2": "Du bör först göra en säkerhetskopia av alla ändringar du har gjort lokalt, om du vill använda dem i framtiden.",
"reset-config-msg-l3": "Är du säker på att du vill fortsätta?",
"data-cleared-msg": "Datarensning har lyckats",
"actions-label": "Åtgärder",
"copy-config-label": "Kopiera konfiguration",
"data-copied-msg": "Konfiguration har kopierats till urklipp",
"reset-config-label": "Återställ konfiguration",
"css-save-btn": "Spara ändringar",
"css-note-label": "Not",
"css-note-l1": "Du måste uppdatera sidan för att dina ändringar ska gälla.",
"css-note-l2": "Styles overrides lagras bara lokalt, så det rekommenderas att du gör en kopia av din CSS.",
"css-note-l3": "För att ta bort alla egendefinierade stilmallar, radera innehållet och tryck på Spara ändringar"
},
"alternate-views": {
"alternate-view-heading": "Ändra vy",
"default": "Standard",
"workspace": "Workspace",
"minimal": "Minimal"
},
"settings": {
"theme-label": "Tema",
"layout-label": "Layout",
"layout-auto": "Auto",
"layout-horizontal": "Vågrät",
"layout-vertical": "Lodrät",
"item-size-label": "Storlek",
"item-size-small": "Liten",
"item-size-medium": "Mellan",
"item-size-large": "Stor",
"config-launcher-label": "Konfig",
"config-launcher-tooltip": "Uppdatera konfiguration",
"sign-out-tooltip": "Logga ut",
"sign-in-tooltip": "Logga in",
"sign-in-welcome": "Hej {username}!"
},
"updates": {
"app-version-note": "Dashy-version",
"up-to-date": "Uppdaterat",
"out-of-date": "Uppdatering finns",
"unsupported-version-l1": "Du använder en icke-stödd version av Dashy",
"unsupported-version-l2": "För den bästa upplevelsen och de senaste säkerhetskorrigeringarna, uppdatera till"
},
"language-switcher": {
"title": "Ändra appspråk",
"dropdown-label": "Välj språk",
"save-button": "Spara",
"success-msg": "Språket har ändrats till"
},
"theme-maker": {
"title": "Temakonfigurator",
"export-button": "Exportera egendefinierade variabler",
"reset-button": "Återställ stilmallar för",
"show-all-button": "Visa alla variabler",
"change-fonts-button": "Ändra typsnitt",
"save-button": "Spara",
"cancel-button": "Avbryt",
"saved-toast": "Uppdatering av {theme} har lyckats",
"copied-toast": "Temadatan för {theme} har kopierats till urklipp",
"reset-toast": "Egendefinierade färger för {theme} har tagits bort"
},
"config-editor": {
"save-location-label": "Sparningsplats",
"location-local-label": "Tillämpa lokalt",
"location-disk-label": "Skriv ändringar till konfigurationsfil",
"save-button": "Spara ändringar",
"preview-button": "Förhandsgranska ändringar",
"valid-label": "Konfigurationen är giltig",
"status-success-msg": "Åtgärden slutförts",
"status-fail-msg": "Åtgärden misslyckats",
"success-msg-disk": "Konfigurationsfil har skrivits till disk utan problem",
"success-msg-local": "Lokala ändringar har sparats utan problem",
"success-note-l1": "Återskapa",
"success-note-l2": "Detta kan ta upp till en minut.",
"success-note-l3": "Du måste uppdatera sidan för att ändringar ska gälla",
"error-msg-save-mode": "Välj Lagringsläge: Lokalt eller Fil",
"error-msg-cannot-save": "Ett fel uppstod när konfigurationen skulle sparas",
"error-msg-bad-json": "Fel i JSON, möjligen felformaterat",
"warning-msg-validation": "Valideringsvarning",
"not-admin-note": "Du kan inte skriva ändringar till disk, eftersom du inte är inloggad som admin"
},
"app-rebuild": {
"title": "Återskapa appen",
"rebuild-note-l1": "Appen måste återskapas för att ändringar som skrivits till filen conf.yml ska gälla.",
"rebuild-note-l2": "Detta bör ske automatiskt, men om det inte har gjort det kan du aktivera det manuellt här.",
"rebuild-note-l3": "Detta krävs inte för ändringar som lagras lokalt.",
"rebuild-button": "Återskapa",
"rebuilding-status-1": "Återskapar...",
"rebuilding-status-2": "Detta kan ta några minuter",
"error-permission": "Du har inte behörighet att utföra denna åtgärd",
"success-msg": "Återskapning lyckats",
"fail-msg": "Återskapning misslyckats",
"reload-note": "En omladdning av sidan krävs nu för att ändringarna ska gälla",
"reload-button": "Ladda om sidan"
},
"cloud-sync": {
"title": "Molnsäkerhetskopiering och återställning",
"intro-l1": "Molnsäkerhetskopiering och återställning är en valfri funktion som gör att du kan ladda upp din konfiguration till internet och sedan återställa den på någon annan enhet eller instans av Dashy.",
"intro-l2": "All data är fullständigt end-to-end krypterad med AES, med ditt lösenord som nyckel.",
"intro-l3": "För mer information, vänligen se",
"backup-title-setup": "Gör en säkerhetskopia",
"backup-title-update": "Uppdatera säkerhetskopia",
"password-label-setup": "Välj lösenord",
"password-label-update": "Ange ditt lösenord",
"backup-button-setup": "Säkerhetskopiering",
"backup-button-update": "Uppdatera säkerhetskopia",
"backup-id-label": "Ditt säkerhetskopierings-ID",
"backup-id-note": "Detta används för att återställa från säkerhetskopior senare. Så förvara det tillsammans med ditt lösenord någonstans säkert.",
"restore-title": "Återställ en säkerhetskopia",
"restore-id-label": "Återställ ID",
"restore-password-label": "Lösenord",
"restore-button": "Återställ",
"backup-missing-password": "Lösenord saknas",
"backup-error-unknown": "Begäran kan inte behandlas",
"backup-error-password": "Fel lösenord. Vänligen ange ditt aktuella lösenord.",
"backup-success-msg": "Slutfört utan problem",
"restore-success-msg": "Konfigurationen har återställts utan problem"
},
"menu": {
"open-section-title": "Öppna i",
"sametab": "Denna flik",
"newtab": "Ny flik",
"modal": "Pop-Up Modal",
"workspace": "Workspace-vy",
"options-section-title": "Alternativ",
"edit-item": "Redigera",
"move-item": "Kopiera eller flytta",
"remove-item": "Ta bort"
},
"context-menus": {
"item": {
"open-section-title": "Öppna i",
"sametab": "Denna flik",
"newtab": "Ny flik",
"modal": "Pop-Up Modal",
"workspace": "Workspace View",
"options-section-title": "Alternativ",
"edit-item": "Redigera",
"move-item": "Kopiera eller flytta",
"remove-item": "Ta bort"
},
"section": {
"open-section": "Öppna sektion",
"edit-section": "Redigera",
"move-section": "Flytta till",
"remove-section": "Ta bort"
}
},
"interactive-editor": {
"menu": {
"start-editing-tooltip": "Öppna den interaktiva redigeraren",
"edit-site-data-subheading": "Redigera webbplatsinformation",
"edit-page-info-btn": "Redigera sidinformation",
"edit-page-info-tooltip": "Appnamn, beskrivning, navigeringslänkar, sidfotstext, etc",
"edit-app-config-btn": "Redigera appkonfiguration",
"edit-app-config-tooltip": "Övriga appkonfigurationsalternativ",
"config-save-methods-subheading": "Alternativ för konfigurationssparande",
"save-locally-btn": "Spara lokalt",
"save-locally-tooltip": "Spara konfigurationen lokalt, till webbläsarens lagring. Detta påverkar inte din konfigurationsfil, men ändringarna sparas bara på denna enhet",
"save-disk-btn": "Spara till disk",
"save-disk-tooltip": "Spara konfiguration to conf.yml-filen på disk. Detta kommer att säkerhetskopiera och sedan skriva över din befintliga konfiguration",
"export-config-btn": "Exportera konfiguration",
"export-config-tooltip": "Visa och exportera den nya konfigurationen, antingen till fil eller urklipp",
"cloud-backup-btn": "Säkerhetskopiera till molnet",
"cloud-backup-tooltip": "Spara krypterad säkerhetskopia av konfigurationen i molnet",
"edit-raw-config-btn": "Redigera raw-konfiguration",
"edit-raw-config-tooltip": "Visa och redigera raw-konfiguration via JSON-redigeraren",
"cancel-changes-btn": "Avbryt redigering",
"cancel-changes-tooltip": "Radera nuvarande ändringar och lämna Redigeringsläge. Detta kommer in påverka din sparade konfiguration.",
"edit-mode-name": "Redigeringsläge",
"edit-mode-subtitle": "Du är i Redigeringsläge",
"edit-mode-description": "Detta innebär att du kan göra ändringar i din konfiguration och förhandsgranska resultaten, men tills du sparar kommer inga av dina ändringar att bevaras.",
"save-stage-btn": "Spara",
"cancel-stage-btn": "Avbryt"
},
"edit-section": {
"edit-section-title": "Redigera sektion",
"add-section-title": "Lägg till ny sektion",
"edit-tooltip": "Tryck för att redigera, eller högerklicka för fler alternativ",
"remove-confirm": "Är du säker på att du vill ta bort denna sektion? Denna åtgärd kan ångras senare."
},
"edit-app-config": {
"warning-msg-title": "Fortsätt med försiktighet",
"warning-msg-l1": "Följande alternativ är för avancerade appkonfigurationer.",
"warning-msg-l2": "Om du är osäker på något av fälten, vänligen kolla",
"warning-msg-docs": "dokumentationen",
"warning-msg-l3": "för att undvika oavsiktliga konsekvenser."
},
"export": {
"export-title": "Exportera konfiguration",
"copy-clipboard-btn": "Kopiera till urklipp",
"copy-clipboard-tooltip": "Kopiera alla appkonfigurationer till systemets urklipp i YAML-format",
"download-file-btn": "Ladda ned som fil",
"download-file-tooltip": "Ladda ner alla appkonfigurationer till din enhet som en YAML-fil",
"view-title": "Visa konfiguration"
}
"section":{
"open-section":"Öppna sektion",
"edit-section":"Redigera",
"move-section":"Flytta till",
"remove-section":"Ta bort"
}
}
},
"interactive-editor":{
"menu":{
"start-editing-tooltip":"Öppna den interaktiva redigeraren",
"edit-site-data-subheading":"Redigera webbplatsinformation",
"edit-page-info-btn":"Redigera sidinformation",
"edit-page-info-tooltip":"Appnamn, beskrivning, navigeringslänkar, sidfotstext, etc",
"edit-app-config-btn":"Redigera appkonfiguration",
"edit-app-config-tooltip":"Övriga appkonfigurationsalternativ",
"config-save-methods-subheading":"Alternativ för konfigurationssparande",
"save-locally-btn":"Spara lokalt",
"save-locally-tooltip":"Spara konfigurationen lokalt, till webbläsarens lagring. Detta påverkar inte din konfigurationsfil, men ändringarna sparas bara på denna enhet",
"save-disk-btn":"Spara till disk",
"save-disk-tooltip":"Spara konfiguration to conf.yml-filen på disk. Detta kommer att säkerhetskopiera och sedan skriva över din befintliga konfiguration",
"export-config-btn":"Exportera konfiguration",
"export-config-tooltip":"Visa och exportera den nya konfigurationen, antingen till fil eller urklipp",
"cloud-backup-btn":"Säkerhetskopiera till molnet",
"cloud-backup-tooltip":"Spara krypterad säkerhetskopia av konfigurationen i molnet",
"edit-raw-config-btn":"Redigera raw-konfiguration",
"edit-raw-config-tooltip":"Visa och redigera raw-konfiguration via JSON-redigeraren",
"cancel-changes-btn":"Avbryt redigering",
"cancel-changes-tooltip":"Radera nuvarande ändringar och lämna Redigeringsläge. Detta kommer in påverka din sparade konfiguration.",
"edit-mode-name":"Redigeringsläge",
"edit-mode-subtitle":"Du är i Redigeringsläge",
"edit-mode-description":"Detta innebär att du kan göra ändringar i din konfiguration och förhandsgranska resultaten, men tills du sparar kommer inga av dina ändringar att bevaras.",
"save-stage-btn":"Spara",
"cancel-stage-btn":"Avbryt"
},
"edit-item":{
"missing-title-err":"Objektet måste ha en titel"
},
"edit-section":{
"edit-section-title":"Redigera sektion",
"add-section-title":"Lägg till ny sektion",
"edit-tooltip":"Tryck för att redigera, eller högerklicka för fler alternativ",
"remove-confirm":"Är du säker på att du vill ta bort denna sektion? Denna åtgärd kan ångras senare."
},
"edit-app-config":{
"warning-msg-title":"Fortsätt med försiktighet",
"warning-msg-l1":"Följande alternativ är för avancerade appkonfigurationer.",
"warning-msg-l2":"Om du är osäker på något av fälten, vänligen kolla",
"warning-msg-docs":"dokumentationen",
"warning-msg-l3":"för att undvika oavsiktliga konsekvenser."
},
"export":{
"export-title":"Exportera konfiguration",
"copy-clipboard-btn":"Kopiera till urklipp",
"copy-clipboard-tooltip":"Kopiera alla appkonfigurationer till systemets urklipp i YAML-format",
"download-file-btn":"Ladda ned som fil",
"download-file-tooltip":"Ladda ner alla appkonfigurationer till din enhet som en YAML-fil",
"view-title":"Visa konfiguration"
}
},
"widgets":{
"general":{
"loading":"Laddar...",
"show-more":"Visa mer info",
"show-less":"Visa mindre",
"open-link":"Läs mer"
},
"pi-hole":{
"status-heading":"Status"
},
"stat-ping":{
"up":"Online",
"down":"Offline"
},
"net-data":{
"cpu-chart-title":"CPU History",
"mem-chart-title":"Memory Usage",
"mem-breakdown-title":"Memory Breakdown",
"load-chart-title":"System Load"
},
"system-info":{
"uptime":"Uptime"
},
"flight-data":{
"arrivals":"Ankomster",
"departures":"Avgångar"
},
"tfl-status":{
"good-service-all":"Good Service på alla linjer",
"good-service-rest":"Good Service på alla övriga linjer"
}
}
}

View File

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

View File

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

View File

@ -7,16 +7,27 @@
<pre v-if="errorLog" class="logs"><code>{{ errorLog }}</code></pre>
<p v-else>No recent errors detected :)</p>
<hr />
<!-- Help Links -->
<!-- Getting Help -->
<h3>Help & Support</h3>
For getting support with running or configuring Dashy, see the <a href="https://github.com/Lissy93/dashy/discussions">Discussions</a>
<!-- Please help out :) -->
<h3>Supporting Dashy</h3>
For ways that you can get involved, check out the <a href="https://github.com/Lissy93/dashy/blob/master/docs/contributing.md">Contributing</a> page.
<!-- Bug Reports -->
<h3>Report a Bug</h3>
If you think you've found a bug, then please <a href="https://github.com/Lissy93/dashy/issues/new/choose">raise an Issue</a>.
<!-- Source and Docs Links -->
<h3>More Info</h3>
Source: <a href="https://github.com/lissy93/dashy">github.com/lissy93/dashy</a><br>
Documentation: <a href="https://dashy.to/docs">dashy.to/docs</a>
<!-- Privacy & Security -->
<h3>Privacy & Security</h3>
For a break-down of how your data is managed by Dashy, see
the <a href="https://github.com/Lissy93/dashy/blob/master/docs/privacy.md">Privacy Policy</a>.<br>
For advise in securing your dashboard, you can reference the
<a href="https://github.com/Lissy93/dashy/blob/master/docs/management.md">Management Docs</a>.<br>
If you've found a potential security issue, report it following our
<a href="https://github.com/Lissy93/dashy/blob/master/.github/SECURITY.md">Security Policy</a>
<!-- License -->
<h3>License</h3>
Licensed under MIT X11. Copyright <a href="https://aliciasykes.com">Alicia Sykes</a> © 2021.<br>

View File

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

View File

@ -198,19 +198,26 @@ export default {
// Convert form data back into section.item data structure
const structured = {};
this.formData.forEach((row) => { structured[row.name] = row.value; });
// Some attributes need a little extra formatting
const newItem = this.formatBeforeSave(structured);
if (this.isNew) { // Insert new item into data store
newItem.id = `temp_${newItem.title}`;
const payload = { newItem, targetSection: this.parentSectionTitle };
this.$store.commit(StoreKeys.INSERT_ITEM, payload);
} else { // Update existing item from form data, in the store
this.$store.commit(StoreKeys.UPDATE_ITEM, { newItem, itemId: this.itemId });
if (!structured.title) { // Missing title, show error and don't proceed
this.$toasted.show(
this.$t('interactive-editor.edit-item.missing-title-err'),
{ className: 'toast-error' },
);
} else {
// Some attributes need a little extra formatting
const newItem = this.formatBeforeSave(structured);
if (this.isNew) { // Insert new item into data store
newItem.id = `temp_${newItem.title}`;
const payload = { newItem, targetSection: this.parentSectionTitle };
this.$store.commit(StoreKeys.INSERT_ITEM, payload);
} else { // Update existing item from form data, in the store
this.$store.commit(StoreKeys.UPDATE_ITEM, { newItem, itemId: this.itemId });
}
// If we're not already in edit mode, enable it now
this.$store.commit(StoreKeys.SET_EDIT_MODE, true);
// Close edit menu
this.$emit('closeEditMenu');
}
// If we're not already in edit mode, enable it now
this.$store.commit(StoreKeys.SET_EDIT_MODE, true);
// Close edit menu
this.$emit('closeEditMenu');
},
/* Some fields require a bit of extra processing before they're saved */
formatBeforeSave(item) {

View File

@ -77,7 +77,7 @@ export default {
currentSection() {
let sectionName = '';
this.sections.forEach((section) => {
section.items.forEach((item) => {
(section.items || []).forEach((item) => {
if (item.id === this.itemId) sectionName = section.name;
});
});

View File

@ -1,6 +1,6 @@
<template>
<div
:class="`collapsable ${rowColSpanClass} ${collapseClass}`"
:class="`collapsable ${rowColSpanClass} ${collapseClass} ${!cutToHeight ? 'full-height' : ''}`"
:style="`${color ? 'background: '+color : ''}; ${sanitizeCustomStyles(customStyles)};`"
>
<input
@ -43,6 +43,7 @@ export default {
rows: Number, // Set section vertical row span / height
color: String, // Optional color override
customStyles: String, // Optional custom stylings
cutToHeight: Boolean, // To set section height with content height
},
components: {
Icon,
@ -252,5 +253,16 @@ export default {
right: 0.5rem;
top: 0.5rem;
}
// Makes sections fill available space
&.is-open.full-height {
height: -webkit-fill-available;
display: flex;
flex-direction: column;
align-items: normal;
.collapsible-content {
height: -webkit-fill-available;
width: 100%;
}
}
}
</style>

View File

@ -9,7 +9,7 @@
v-tooltip="getTooltipOptions()"
rel="noopener noreferrer" tabindex="0"
:id="`link-${id}`"
:style="`--open-icon: ${getUnicodeOpeningIcon()}; ${customStyles}`"
:style="`--open-icon: ${getUnicodeOpeningIcon()}; color: ${color}; ${customStyles}`"
>
<!-- Item Text -->
<div :class="`tile-title ${!icon? 'bounce no-icon': ''}`" :id="`tile-${id}`" >
@ -141,7 +141,7 @@ export default {
hyperLinkHref() {
const nothing = '#';
if (this.isEditMode) return nothing;
const noAnchorNeeded = ['modal', 'workspace'];
const noAnchorNeeded = ['modal', 'workspace', 'clipboard'];
return noAnchorNeeded.includes(this.accumulatedTarget) ? nothing : this.url;
},
},
@ -174,6 +174,9 @@ export default {
this.$emit('triggerModal', this.url);
} else if (this.accumulatedTarget === 'workspace') {
router.push({ name: 'workspace', query: { url: this.url } });
} else if (this.accumulatedTarget === 'clipboard') {
navigator.clipboard.writeText(this.url);
this.$toasted.show(this.$t('context-menus.item.copied-toast'));
} else {
this.$emit('itemClicked');
}
@ -226,6 +229,7 @@ export default {
case 'top': return '"\\f102"';
case 'modal': return '"\\f2d0"';
case 'workspace': return '"\\f0b1"';
case 'clipboard': return '"\\f0ea"';
default: return '"\\f054"';
}
},
@ -279,6 +283,10 @@ export default {
case 'workspace':
router.push({ name: 'workspace', query: { url } });
break;
case 'clipboard':
navigator.clipboard.writeText(url);
this.$toasted.show(this.$t('context-menus.item.copied-toast'));
break;
default: window.open(url, '_blank');
}
},
@ -452,7 +460,7 @@ export default {
height: 2rem;
padding-top: 4px;
max-width: 14rem;
div img, div svg.missing-image {
div img {
width: 2rem;
}
.tile-title {
@ -473,7 +481,7 @@ export default {
flex-direction: column;
align-items: center;
height: auto;
div img, div svg.missing-image {
div img {
width: 2.5rem;
margin-bottom: 0.25rem;
}
@ -546,4 +554,7 @@ a.item.is-edit-mode {
.disabled-link {
pointer-events: none;
}
.tooltip.item-description-tooltip {
z-index: 7;
}
</style>

View File

@ -23,6 +23,10 @@
<WorkspaceOpenIcon />
<span>{{ $t('context-menus.item.workspace') }}</span>
</li>
<li @click="launch('clipboard')">
<ClipboardOpenIcon />
<span>{{ $t('context-menus.item.clipboard') }}</span>
</li>
</ul>
<!-- Edit Options -->
<ul class="menu-section">
@ -55,6 +59,7 @@ import SameTabOpenIcon from '@/assets/interface-icons/open-current-tab.svg';
import NewTabOpenIcon from '@/assets/interface-icons/open-new-tab.svg';
import IframeOpenIcon from '@/assets/interface-icons/open-iframe.svg';
import WorkspaceOpenIcon from '@/assets/interface-icons/open-workspace.svg';
import ClipboardOpenIcon from '@/assets/interface-icons/open-clipboard.svg';
export default {
name: 'ContextMenu',
@ -66,6 +71,7 @@ export default {
NewTabOpenIcon,
IframeOpenIcon,
WorkspaceOpenIcon,
ClipboardOpenIcon,
},
props: {
posX: Number, // The X coordinate for positioning

View File

@ -1,13 +1,14 @@
<template>
<div :class="`item-icon wrapper-${size}`">
<div v-if="icon" :class="`item-icon wrapper-${size}`">
<!-- Font-Awesome Icon -->
<i v-if="iconType === 'font-awesome'" :class="`${icon} ${size}`" ></i>
<!-- Emoji Icon -->
<i v-else-if="iconType === 'emoji'" :class="`emoji-icon ${size}`" >{{getEmoji(iconPath)}}</i>
<!-- Material Design Icon -->
<span v-else-if="iconType === 'mdi'" :class=" `mdi ${icon} ${size}`"></span>
<span v-else-if="iconType === 'mdi'" :class=" `mdi ${icon} ${size}`"></span>
<!-- Simple-Icons -->
<svg v-else-if="iconType === 'si'" :class="`simple-icons ${size}`" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg v-else-if="iconType === 'si' && !broken" :class="`simple-icons ${size}`"
role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path :d="getSimpleIcon(icon)" />
</svg>
<!-- Standard image asset icon -->
@ -15,7 +16,7 @@
:class="`tile-icon ${size} ${broken ? 'broken' : ''}`"
/>
<!-- Icon could not load/ broken url -->
<BrokenImage v-if="broken" class="missing-image" />
<BrokenImage v-if="broken" :class="`missing-image ${size}`" />
</div>
</template>
@ -25,8 +26,8 @@ import BrokenImage from '@/assets/interface-icons/broken-icon.svg';
import ErrorHandler from '@/utils/ErrorHandler';
import EmojiUnicodeRegex from '@/utils/EmojiUnicodeRegex';
import emojiLookup from '@/utils/emojis.json';
import { faviconApi as defaultFaviconApi, faviconApiEndpoints, iconCdns } from '@/utils/defaults';
import { asciiHash } from '@/utils/MiscHelpers';
import { faviconApi as defaultFaviconApi, faviconApiEndpoints, iconCdns } from '@/utils/defaults';
export default {
name: 'Icon',
@ -36,7 +37,7 @@ export default {
size: String, // Either small, medium or large
},
components: {
BrokenImage,
BrokenImage, // Used when the desired image returns a 404
},
computed: {
/* Get appConfig from store */
@ -60,6 +61,39 @@ export default {
};
},
methods: {
/* Determine icon type, e.g. local or remote asset, SVG, favicon, font-awesome, etc */
determineImageType(img) {
let imgType = '';
if (!img) imgType = 'none';
else if (this.isUrl(img)) imgType = 'url';
else if (this.isImage(img)) imgType = 'img';
else if (img.includes('fa-')) imgType = 'font-awesome';
else if (img.includes('mdi-')) imgType = 'mdi';
else if (img.includes('si-')) imgType = 'si';
else if (img.includes('hl-')) imgType = 'home-lab-icons';
else if (img.includes('favicon-')) imgType = 'custom-favicon';
else if (img === 'favicon') imgType = 'favicon';
else if (img === 'generative') imgType = 'generative';
else if (this.isEmoji(img).isEmoji) imgType = 'emoji';
else imgType = 'none';
return imgType;
},
/* Return the path to icon asset, depending on icon type */
getIconPath(img, url) {
switch (this.determineImageType(img)) {
case 'url': return img;
case 'img': return this.getLocalImagePath(img);
case 'favicon': return this.getFavicon(url);
case 'custom-favicon': return this.getCustomFavicon(url, img);
case 'generative': return this.getGenerativeIcon(url);
case 'mdi': return img; // Material design icons
case 'simple-icons': return this.getSimpleIcon(img);
case 'home-lab-icons': return this.getHomeLabIcon(img);
case 'svg': return img; // Local SVG icon
case 'emoji': return img; // Emoji/ unicode
default: return '';
}
},
/* Check if a string is in a URL format. Used to identify tile icon source */
isUrl(str) {
const pattern = new RegExp(/(http|https):\/\/(\w+:{0,1}\w*)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%!\-/]))?/);
@ -73,7 +107,7 @@ export default {
if (splitPath.length >= 1) return validImgExtensions.includes(splitPath[1]);
return false;
},
/* Determins if a given string is an emoji, and if so what type it is */
/* Determines if a given string is an emoji, and if so what type it is */
isEmoji(img) {
if (EmojiUnicodeRegex.test(img) && img.match(/./gu).length) { // Is a unicode emoji
return { isEmoji: true, emojiType: 'glyph' };
@ -84,15 +118,27 @@ export default {
}
return { isEmoji: false, emojiType: '' };
},
/* Formats and gets emoji from unicode or shortcode */
/* Returns the corresponding emoji for a shortcode, or shows error if not found */
getShortCodeEmoji(emojiCode) {
if (emojiLookup[emojiCode]) {
return emojiLookup[emojiCode];
} else {
this.imageNotFound(`No emoji found with name '${emojiCode}'`);
return null;
}
},
/* Formats and gets emoji from either unicode, shortcode or glyph */
getEmoji(emojiCode) {
const { emojiType } = this.isEmoji(emojiCode);
if (emojiType === 'shortcode') {
if (emojiLookup[emojiCode]) return emojiLookup[emojiCode];
} else if (emojiType === 'unicode') {
if (emojiType === 'shortcode') { // Short code emoji
return this.getShortCodeEmoji(emojiCode);
} else if (emojiType === 'unicode') { // Unicode emoji
return String.fromCodePoint(parseInt(emojiCode.substr(2), 16));
} else if (emojiType === 'glyph') { // Emoji is a glyph
return emojiCode;
}
return emojiCode; // Emoji is a glyph already, just return
this.imageNotFound(`Unrecognized emoji: '${emojiCode}'`);
return null;
},
/* Get favicon URL, for items which use the favicon as their icon */
getFavicon(fullUrl, specificApi) {
@ -109,16 +155,17 @@ export default {
},
/* Get the URL for a favicon, but using the non-default favicon API */
getCustomFavicon(fullUrl, faviconIdentifier) {
let errorMsg = '';
const faviconApi = faviconIdentifier.split('favicon-')[1];
if (!faviconApi) {
ErrorHandler('Favicon API not specified');
} else if (!Object.keys(faviconApiEndpoints).includes(faviconApi)) {
ErrorHandler(`The specified favicon API, '${faviconApi}' cannot be found.`);
errorMsg = 'Favicon API not specified';
} else if (!Object.keys(faviconApiEndpoints).includes(faviconApi) && faviconApi !== 'local') {
errorMsg = `The specified favicon API, '${faviconApi}' cannot be found.`;
} else {
return this.getFavicon(fullUrl, faviconApi);
}
// Error encountered, favicon service not found
this.broken = true;
this.imageNotFound(errorMsg);
return undefined;
},
/* If using favicon for icon, and if service is running locally (determined by local IP) */
@ -140,69 +187,53 @@ export default {
getSimpleIcon(img) {
const imageName = img.replace('si-', '');
const icon = simpleIcons.Get(imageName);
if (!icon) {
this.imageNotFound(`No icon was found for '${imageName}' in Simple Icons`);
return null;
}
return icon.path;
},
/* Gets home-lab icon from GitHub */
getHomeLabIcon(img) {
getHomeLabIcon(img, cdn) {
const imageName = img.replace('hl-', '').toLocaleLowerCase();
return iconCdns.homeLabIcons.replace('{icon}', imageName);
},
/* Checks if the icon is from a local image, remote URL, SVG or font-awesome */
getIconPath(img, url) {
switch (this.determineImageType(img)) {
case 'url': return img;
case 'img': return this.getLocalImagePath(img);
case 'favicon': return this.getFavicon(url);
case 'custom-favicon': return this.getCustomFavicon(url, img);
case 'generative': return this.getGenerativeIcon(url);
case 'mdi': return img; // Material design icons
case 'simple-icons': return this.getSimpleIcon(img);
case 'home-lab-icons': return this.getHomeLabIcon(img);
case 'svg': return img; // Local SVG icon
case 'emoji': return img; // Emoji/ unicode
default: return '';
}
},
/* Checks if the icon is from a local image, remote URL, SVG or font-awesome */
determineImageType(img) {
let imgType = '';
if (!img) imgType = 'none';
else if (this.isUrl(img)) imgType = 'url';
else if (this.isImage(img)) imgType = 'img';
else if (img.includes('fa-')) imgType = 'font-awesome';
else if (img.includes('mdi-')) imgType = 'mdi';
else if (img.includes('si-')) imgType = 'si';
else if (img.includes('hl-')) imgType = 'home-lab-icons';
else if (img.includes('favicon-')) imgType = 'custom-favicon';
else if (img === 'favicon') imgType = 'favicon';
else if (img === 'generative') imgType = 'generative';
else if (this.isEmoji(img).isEmoji) imgType = 'emoji';
else imgType = 'none';
return imgType;
return (cdn || iconCdns.homeLabIcons).replace('{icon}', imageName);
},
/* For a given URL, return the hostname only. Used for favicon and generative icons */
getHostName(url) {
try { return new URL(url).hostname; } catch (e) { return url; }
try {
return new URL(url).hostname.split('.').slice(-2).join('.');
} catch (e) {
ErrorHandler('Unable to format URL');
return url;
}
},
/* Called when the path to the image cannot be resolved */
imageNotFound() {
imageNotFound(errorMsg) {
let outputMessage = '';
if (errorMsg && typeof errorMsg === 'string') {
outputMessage = errorMsg;
} else if (!this.icon) {
outputMessage = 'Icon not specified';
} else {
outputMessage = `The path to '${this.icon}' could not be resolved`;
}
ErrorHandler(outputMessage);
this.broken = true;
ErrorHandler(`The path to '${this.icon}' could not be resolved`);
},
/* Called when initial icon has resulted in 404. Attempts to find new icon */
getFallbackIcon() {
if (this.attemptedFallback) return undefined; // If this is second attempt, then give up
const { iconType } = this;
const markAsSttempted = () => {
this.broken = false;
this.attemptedFallback = true;
};
const markAsAttempted = () => { this.broken = false; this.attemptedFallback = true; };
if (iconType.includes('favicon')) { // Specify fallback for favicon-based icons
markAsSttempted();
markAsAttempted();
return this.getFavicon(this.url, 'local');
} else if (iconType === 'generative') {
markAsSttempted();
markAsAttempted();
return this.getGenerativeIcon(this.url, iconCdns.generativeFallback);
} else if (iconType === 'home-lab-icons') {
markAsAttempted();
return this.getHomeLabIcon(this.icon, iconCdns.homeLabIconsFallback);
}
return undefined;
},
@ -290,7 +321,13 @@ export default {
}
/* Icon Not Found */
.missing-image {
width: 3.5rem;
width: 2rem;
&.small {
width: 1.5rem !important;
}
&.large {
width: 2.5rem;
}
path {
fill: currentColor;
}

View File

@ -7,6 +7,7 @@
<WorkspaceOpenIcon v-else-if="openingMethod === 'workspace'" />
<ParentOpenIcon v-else-if="openingMethod === 'parent'" />
<TopOpenIcon v-else-if="openingMethod === 'top'" />
<ClipboardOpenIcon v-else-if="openingMethod === 'clipboard'" />
<UnknownIcon v-else />
</div>
<div v-if="hotkey" :class="`hotkey-denominator ${makeClass(position, isSmall, isTransparent)}`">
@ -25,6 +26,7 @@ import IframeOpenIcon from '@/assets/interface-icons/open-iframe.svg';
import WorkspaceOpenIcon from '@/assets/interface-icons/open-workspace.svg';
import ParentOpenIcon from '@/assets/interface-icons/open-parent.svg';
import TopOpenIcon from '@/assets/interface-icons/open-top.svg';
import ClipboardOpenIcon from '@/assets/interface-icons/open-clipboard.svg';
import UnknownIcon from '@/assets/interface-icons/unknown-icon.svg';
export default {
@ -52,6 +54,7 @@ export default {
WorkspaceOpenIcon,
ParentOpenIcon,
TopOpenIcon,
ClipboardOpenIcon,
UnknownIcon,
},
};

View File

@ -8,15 +8,16 @@
:rows="displayData.rows"
:color="displayData.color"
:customStyles="displayData.customStyles"
:cutToHeight="displayData.cutToHeight"
@openEditSection="openEditSection"
@openContextMenu="openContextMenu"
>
<!-- If no items, show message -->
<div v-if="(!items || items.length < 1) && !isEditMode" class="no-items">
No Items to Show Yet
<div v-if="isEmpty" class="no-items">
{{ $t('home.no-items-section') }}
</div>
<!-- Item Container -->
<div v-else
<div v-if="hasItems"
:class="`there-are-items ${isGridLayout? 'item-group-grid': ''} inner-size-${itemSize}`"
:style="gridStyle" :id="`section-${groupId}`"
> <!-- Show for each item -->
@ -33,12 +34,12 @@
:backgroundColor="item.backgroundColor"
:statusCheckUrl="item.statusCheckUrl"
:statusCheckHeaders="item.statusCheckHeaders"
:itemSize="newItemSize"
:itemSize="itemSize"
:hotkey="item.hotkey"
:provider="item.provider"
:parentSectionTitle="title"
:enableStatusCheck="shouldEnableStatusCheck(item.statusCheck)"
:statusCheckInterval="getStatusCheckInterval()"
:enableStatusCheck="item.statusCheck !== undefined ? item.statusCheck : enableStatusCheck"
:statusCheckInterval="statusCheckInterval"
:statusCheckAllowInsecure="item.statusCheckAllowInsecure"
@itemClicked="$emit('itemClicked')"
@triggerModal="triggerModal"
@ -54,9 +55,19 @@
description="Click to add new item"
key="add-new"
class="add-new-item"
:itemSize="newItemSize"
:itemSize="itemSize"
/>
</div>
<div
v-if="hasWidgets"
:class="`widget-list ${isWide? 'wide' : ''}`">
<WidgetBase
v-for="(widget, widgetIndx) in widgets"
:key="widgetIndx"
:widget="widget"
:index="index"
@navigateToSection="navigateToSection"
/>
<div ref="modalContainer"></div>
</div>
<!-- Modal for opening in modal view -->
<IframeModal
@ -88,6 +99,7 @@
<script>
import router from '@/router';
import Item from '@/components/LinkItems/Item.vue';
import WidgetBase from '@/components/Widgets/WidgetBase';
import Collapsable from '@/components/LinkItems/Collapsable.vue';
import IframeModal from '@/components/LinkItems/IframeModal.vue';
import EditSection from '@/components/InteractiveEditor/EditSection.vue';
@ -108,13 +120,15 @@ export default {
icon: String,
displayData: Object,
items: Array,
itemSize: String,
widgets: Array,
index: Number,
isWide: Boolean,
},
components: {
Collapsable,
ContextMenu,
Item,
WidgetBase,
IframeModal,
EditSection,
},
@ -132,9 +146,25 @@ export default {
appConfig() {
return this.$store.getters.appConfig;
},
isEditMode() {
return this.$store.state.editMode;
},
itemSize() {
return this.$store.getters.iconSize;
},
sortOrder() {
return this.displayData.sortBy || defaultSortOrder;
},
hasItems() {
if (this.isEditMode) return true;
return this.items && this.items.length > 0;
},
hasWidgets() {
return this.widgets && this.widgets.length > 0;
},
isEmpty() {
return !this.hasItems && !this.hasWidgets;
},
/* If the sortBy attribute is specified, then return sorted data */
sortedItems() {
let { items } = this;
@ -146,7 +176,7 @@ export default {
} else if (this.sortOrder === 'most-used') {
items = this.sortByMostUsed(items);
} else if (this.sortOrder === 'last-used') {
items = this.sortBLastUsed(items);
items = this.sortByLastUsed(items);
} else if (this.sortOrder === 'random') {
items = this.sortRandomly(items);
} else if (this.sortOrder && this.sortOrder !== 'default') {
@ -154,9 +184,6 @@ export default {
}
return items;
},
newItemSize() {
return this.displayData.itemSize || this.itemSize;
},
isGridLayout() {
return this.displayData.sectionLayout === 'grid'
|| !!(this.displayData.itemCountX || this.displayData.itemCountY);
@ -171,8 +198,17 @@ export default {
}
return styles;
},
isEditMode() {
return this.$store.state.editMode;
/* Determines if user has enabled online status checks */
enableStatusCheck() {
return this.appConfig.statusCheck || false;
},
/* Determine how often to re-fire status checks */
statusCheckInterval() {
let interval = this.appConfig.statusCheckInterval;
if (!interval) return 0;
if (interval > 60) interval = 60;
if (interval < 1) interval = 0;
return interval;
},
},
methods: {
@ -180,19 +216,6 @@ export default {
triggerModal(url) {
this.$refs[`iframeModal-${this.groupId}`].show(url);
},
/* Determines if user has enabled online status checks */
shouldEnableStatusCheck(itemPreference) {
const globalPreference = this.appConfig.statusCheck || false;
return itemPreference !== undefined ? itemPreference : globalPreference;
},
/* Determine how often to re-fire status checks */
getStatusCheckInterval() {
let interval = this.appConfig.statusCheckInterval;
if (!interval) return 0;
if (interval > 60) interval = 60;
if (interval < 1) interval = 0;
return interval;
},
/* Sorts items alphabetically using the title attribute */
sortAlphabetically(items) {
return items.sort((a, b) => (a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1));
@ -205,7 +228,7 @@ export default {
return items;
},
/* Sorts items by most recently used */
sortBLastUsed(items) {
sortByLastUsed(items) {
const usageCount = JSON.parse(localStorage.getItem(localStorageKeys.LAST_USED) || '{}');
const glu = (item) => usageCount[item.id] || 0;
items.reverse().sort((a, b) => (glu(a) < glu(b) ? 1 : -1));
@ -330,4 +353,17 @@ export default {
border-style: dashed;
}
}
.widget-list {
&.wide {
display: flex;
align-items: flex-start;
justify-content: space-around;
.widget-base {
min-width: 10rem;
width: -webkit-fill-available;
}
}
}
</style>

View File

@ -1,24 +1,33 @@
<template>
<div
@click="selectSection(index)"
:class="`minimal-section-heading ${selected ? 'selected' : ''}`">
<h3>{{ title }}</h3>
@click="selectSection(index)" v-tooltip="tooltip()"
v-bind:class="{ selected: selected, 'minimal-section-heading': true, center: hideTitleText }">
<Icon v-if="icon" :icon="icon" size="small" class="section-icon" />
<h3 class="section-title" v-if="!hideTitleText">{{ title }}</h3>
</div>
</template>
<script>
import Icon from '@/components/LinkItems/ItemIcon.vue';
export default {
name: 'MinimalHeadings',
components: { Icon },
props: {
index: Number,
title: String,
icon: String,
selected: Boolean,
hideTitleText: Boolean,
},
methods: {
selectSection(index) {
this.$emit('sectionSelected', index);
},
tooltip() {
return this.hideTitleText
? { content: this.title, trigger: 'hover focus', delay: 250 } : null;
},
},
};
</script>
@ -28,23 +37,33 @@ export default {
@import '@/styles/style-helpers.scss';
div.minimal-section-heading {
display: flex;
cursor: pointer;
padding: 0.5rem 0.25rem;
margin-bottom: 0;
padding: 0.5rem 0.25rem;
justify-content: flex-start;
align-items: center;
background: var(--minimal-view-section-heading-background);
border: 1px solid var(--minimal-view-section-heading-color);
border-bottom: none;
border-radius: var(--curve-factor) var(--curve-factor) 0 0;
h3 {
h3.section-title {
margin: 0;
color: var(--minimal-view-section-heading-color);
}
.section-icon {
color: var(--minimal-view-section-heading-color);
margin-right: 0.2rem;
}
&.selected {
background: var(--minimal-view-section-heading-color);
h3 {
h3.section-title, .section-icon {
color: var(--minimal-view-section-heading-background);
}
}
&.center {
justify-content: center;
}
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div :class="`minimal-section-inner ${selected ? 'selected' : ''} ${showAll ? 'show-all': ''}`">
<div class="section-items" v-if="selected || showAll">
<div class="section-items" v-if="items && (selected || showAll)">
<Item
v-for="(item, index) in items"
:id="`${index}_${makeId(item.title)}`"
@ -22,6 +22,15 @@
@triggerModal="triggerModal"
/>
</div>
<div v-if="widgets && (selected && !showAll)" class="minimal-widget-wrap">
<WidgetBase
v-for="(widget, widgetIndx) in widgets"
:key="widgetIndx"
:widget="widget"
:index="widgetIndx"
@navigateToSection="navigateToSection"
/>
</div>
<IframeModal
:ref="`iframeModal-${groupId}`"
:name="`iframeModal-${groupId}`"
@ -31,7 +40,9 @@
</template>
<script>
import router from '@/router';
import Item from '@/components/LinkItems/Item.vue';
import WidgetBase from '@/components/Widgets/WidgetBase';
import IframeModal from '@/components/LinkItems/IframeModal.vue';
export default {
@ -42,6 +53,7 @@ export default {
icon: String,
displayData: Object,
items: Array,
widgets: Array,
itemSize: String,
modalOpen: Boolean,
index: Number,
@ -55,6 +67,7 @@ export default {
},
components: {
Item,
WidgetBase,
IframeModal,
},
methods: {
@ -80,6 +93,13 @@ export default {
if (interval < 1) interval = 0;
return interval;
},
/* Navigate to the section's single-section view page */
navigateToSection() {
const parse = (section) => section.replace(' ', '-').toLowerCase().trim();
const sectionIdentifier = parse(this.title);
router.push({ path: `/home/${sectionIdentifier}` });
this.closeContextMenu();
},
},
};
</script>
@ -105,6 +125,9 @@ export default {
@include big-screen-up { --minimal-col-count: 6; }
grid-template-columns: repeat(var(--minimal-col-count, 1), minmax(0, 1fr));
}
.minimal-widget-wrap {
padding: 1rem;
}
&.selected {
border: 1px solid var(--minimal-view-group-color);
grid-column-start: span var(--col-count, 3);

View File

@ -12,7 +12,7 @@
<script>
import { shouldBeVisible } from '@/utils/MiscHelpers';
import { shouldBeVisible } from '@/utils/SectionHelpers';
export default {
name: 'Footer',

View File

@ -13,7 +13,7 @@
<script>
import PageTitle from '@/components/PageStrcture/PageTitle.vue';
import Nav from '@/components/PageStrcture/Nav.vue';
import { shouldBeVisible } from '@/utils/MiscHelpers';
import { shouldBeVisible } from '@/utils/SectionHelpers';
export default {
name: 'Header',

View File

@ -1,7 +1,7 @@
<template>
<div>
<!-- If auth configured, show status text -->
<span class="user-type-note">{{ makeText() }}</span>
<span class="user-type-note">{{ makeUserGreeting() }}</span>
<div class="display-options">
<!-- If user logged in, show logout button -->
<IconLogout
@ -17,6 +17,13 @@
v-tooltip="tooltip($t('settings.sign-in-tooltip'))"
class="layout-icon" tabindex="-2"
/>
<!-- If user logged in via keycloak, show keycloak logout button -->
<IconLogout
v-if="userType == userStateEnum.keycloakEnabled"
@click="keycloakLogout()"
v-tooltip="tooltip($t('settings.sign-out-tooltip'))"
class="layout-icon" tabindex="-2"
/>
</div>
</div>
</template>
@ -24,6 +31,7 @@
<script>
import router from '@/router';
import { logout as registerLogout } from '@/utils/Auth';
import { getKeycloakAuth } from '@/utils/KeycloakAuth';
import { localStorageKeys, userStateEnum } from '@/utils/defaults';
import IconLogout from '@/assets/interface-icons/user-logout.svg';
@ -48,14 +56,22 @@ export default {
router.push({ path: '/login' });
}, 500);
},
keycloakLogout() {
const keycloak = getKeycloakAuth();
this.$toasted.show(this.$t('login.logout-message'));
setTimeout(() => {
keycloak.logout();
}, 500);
},
goToLogin() {
router.push({ path: '/login' });
},
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };
},
makeText() {
if (this.userType === userStateEnum.loggedIn) {
makeUserGreeting() {
if (this.userType === userStateEnum.loggedIn
|| this.userType === userStateEnum.keycloakEnabled) {
const username = localStorage[localStorageKeys.USERNAME];
return username ? this.$t('settings.sign-in-welcome', { username }) : '';
}
@ -73,7 +89,6 @@ export default {
span.user-type-note {
color: var(--settings-text-color);
text-transform: capitalize;
margin-right: 0.5rem;
}

View File

@ -10,7 +10,7 @@
<LayoutSelector :displayLayout="displayLayout" />
<ItemSizeSelector :iconSize="iconSize" />
<ConfigLauncher />
<AuthButtons v-if="userState != 'noone'" :userType="userState" />
<AuthButtons v-if="userState !== 0" :userType="userState" />
</div>
<div :class="`show-hide-container ${settingsVisible? 'hide-btn' : 'show-btn'}`">
<button @click="toggleSettingsVisibility()"
@ -80,7 +80,7 @@ export default {
/**
* Determines which button should display, based on the user type
* 0 = Auth not configured, don't show anything
* 1 = Auth condifured, and user logged in, show logout button
* 1 = Auth configured, and user logged in, show logout button
* 2 = Auth configured, guest access enabled, and not logged in, show login
* Note that if auth is enabled, but not guest access, and user not logged in,
* then they will never be able to view the homepage, so no button needed

View File

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

View File

@ -0,0 +1,112 @@
<template>
<div class="apod-wrapper" v-if="image">
<a :href="link" class="title" target="__blank" title="View Article">
{{ title }}
</a>
<a :href="hdImage" title="View HD Image" class="picture" target="__blank">
<img :src="image" :alt="title" />
</a>
<p class="copyright">{{ copyright }}</p>
<p class="description">{{ truncatedDescription }}</p>
<p @click="toggleShowFull" class="expend-details-btn">
{{ showFullDesc ? $t('widgets.general.show-less') : $t('widgets.general.show-more') }}
</p>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
data() {
return {
title: null,
image: null,
hdImage: null,
link: null,
description: null,
copyright: null,
showFullDesc: false,
};
},
computed: {
truncatedDescription() {
return this.showFullDesc ? this.description : `${this.description.substring(0, 100)}...`;
},
},
methods: {
fetchData() {
axios.get(widgetApiEndpoints.astronomyPictureOfTheDay)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
processData(data) {
this.title = data.title;
this.image = data.url;
this.hdImage = data.hdurl;
this.link = data.apod_site;
this.description = data.description;
this.copyright = data.copyright;
},
toggleShowFull() {
this.showFullDesc = !this.showFullDesc;
},
},
};
</script>
<style scoped lang="scss">
.apod-wrapper {
a.title {
font-size: 1.5rem;
margin: 0.5rem 0;
color: var(--widget-text-color);
text-decoration: none;
&:hover { text-decoration: underline; }
}
a.picture img {
width: 100%;
margin: 0.5rem auto;
border-radius: var(--curve-factor);
}
p.copyright {
font-size: 0.8rem;
margin: 0.2rem 0;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
p.description {
color: var(--widget-text-color);
font-size: 1rem;
margin: 0.5rem 0;
}
p.expend-details-btn {
cursor: pointer;
float: right;
margin: 0;
padding: 0.1rem 0.25rem;
border: 1px solid transparent;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
border-radius: var(--curve-factor);
&:hover {
border: 1px solid var(--widget-text-color);
}
&:focus, &:active {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
}
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<div class="placeholder-widget">
<p>Error finding and mounting specified widget</p>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
mounted() {
this.error('Unable to render widget of specified type');
},
};
</script>
<style scoped lang="scss">
.placeholder-widget p {
color: var(--danger);
font-weight: bold;
}
</style>

View File

@ -0,0 +1,105 @@
<template>
<div class="clock">
<div class="upper" v-if="!options.hideDate">
<p class="city">{{ cityName }}</p>
<p class="date">{{ date }}</p>
</div>
<p class="time">{{ time }}</p>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
data() {
return {
time: null, // Current time string
date: null, // Current date string
timeUpdateInterval: null, // Stores setInterval function
};
},
computed: {
/* Get time zone, either specified by user or calculated from browser */
timeZone() {
if (this.options.timeZone) return this.options.timeZone;
return Intl.DateTimeFormat().resolvedOptions().timeZone;
},
/* Get date/time format specification, either user choice, or from browser lang */
timeFormat() {
if (this.options.format) return this.options.format;
return navigator.language;
},
/* Get city name from time-zone, or return users custom city name */
cityName() {
if (this.options.customCityName) return this.options.customCityName;
return this.timeZone.split('/')[1].replaceAll('_', ' ');
},
},
methods: {
update() {
this.setTime();
this.setDate();
},
/* Get and format the current time */
setTime() {
this.time = Intl.DateTimeFormat(this.timeFormat, {
timeZone: this.timeZone,
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
}).format();
},
/* Get and format the date */
setDate() {
this.date = new Date().toLocaleDateString(this.timeFormat, {
weekday: 'long', day: 'numeric', year: 'numeric', month: 'short',
});
},
},
created() {
// Set initial date and time
this.update();
// Update the time and date every second (1000 ms)
this.timeUpdateInterval = setInterval(this.update, 1000);
},
beforeDestroy() {
// Remove the clock interval listener
clearInterval(this.timeUpdateInterval);
},
};
</script>
<style scoped lang="scss">
@font-face {
font-family: 'Digital';
src: url('/fonts/Digital-Regular.ttf');
}
.clock {
padding: 0.5rem 0;
.upper {
display: flex;
justify-content: space-between;
border-radius: var(--curve-factor);
padding: 0.5rem;
opacity: 0.85;
font-size: 0.8rem;
background: var(--widget-accent-color);
}
p {
color: var(--widget-text-color);
cursor: default;
margin: 0;
}
.time {
font-size: 4rem;
padding: 0.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
font-family: Digital, var(--font-monospace);
}
}
</style>

View File

@ -0,0 +1,244 @@
<template>
<div class="code-stats-wrapper">
<!-- User Info -->
<div class="user-meta" v-if="basicInfo && !hideMeta">
<div class="user-info-wrap">
<p class="username">{{ basicInfo.username }}</p>
<p class="user-level">{{ basicInfo.level }}</p>
</div>
<div class="total-xp-wrap">
<p class="total-xp">{{ basicInfo.totalXp | formatTotalXp }}</p>
<p class="new-xp">{{ basicInfo.newXp | formatNewXp }}</p>
</div>
</div>
<!-- XP History Heatmap -->
<div :id="`xp-history-${chartId}`" class="xp-heat-chart"></div>
<!-- Language Breakdown -->
<div :id="`languages-${chartId}`" class="language-pie-chart"></div>
<!-- Machines Percentage -->
<div :id="`machines-${chartId}`" class="machine-percentage-chart"></div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { putCommasInBigNum, showNumAsThousand } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, ChartingMixin],
data() {
return {
basicInfo: null,
};
},
computed: {
/* The username to fetch data from - REQUIRED */
username() {
if (!this.options.username) this.error('You must specify a username');
return this.options.username;
},
/* Optionally override hostname, if using a self-hosted instance */
hostname() {
if (this.options.hostname) return this.options.hostname;
return widgetApiEndpoints.codeStats;
},
hideMeta() {
return this.options.hideMeta || false;
},
hideHistory() {
return this.options.hideHistory || false;
},
hideLanguages() {
return this.options.hideLanguages || false;
},
hideMachines() {
return this.options.hideMachines || false;
},
monthsToShow() {
return this.options.monthsToShow || 5;
},
endpoint() {
return `${this.hostname}/api/users/${this.username}`;
},
chartStartDate() {
const now = new Date();
return new Date((now.setMonth(now.getMonth() - this.monthsToShow)));
},
},
filters: {
formatTotalXp(bigNum) {
return showNumAsThousand(bigNum);
},
formatNewXp(newXp) {
return `+${putCommasInBigNum(newXp)} XP`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data from CodeStats.net', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
// Make basic info data
if (!this.hideMeta) {
this.basicInfo = {
username: data.user,
level: this.makeLevel(data.total_xp),
totalXp: data.total_xp,
newXp: data.new_xp,
};
}
// Make language breakdown pie chart data
if (!this.hideLanguages) {
const langLabels = [];
const langXpValues = [];
Object.keys(data.languages).forEach((lang) => {
langLabels.push(lang);
langXpValues.push(data.languages[lang].xps);
});
const languagesPieData = {
labels: langLabels,
datasets: [{ values: langXpValues }],
};
this.drawLanguagePieChart(languagesPieData);
}
// Make day-by-day historical XP heat chart data
if (!this.hideHistory) {
const xpHistoryChartData = {};
Object.keys(data.dates).forEach((date) => {
const timestamp = Math.round(new Date(date).getTime() / 1000);
xpHistoryChartData[timestamp] = data.dates[date];
});
this.drawXpHistoryChart(xpHistoryChartData);
}
// Make machine proportion percentage chart data
if (!this.hideMachines) {
const machinesLabels = [];
const machinesXpValues = [];
Object.keys(data.machines).forEach((machine) => {
machinesLabels.push(machine);
machinesXpValues.push(data.machines[machine].xps);
});
const machinesPercentageData = {
labels: machinesLabels,
datasets: [{ values: machinesXpValues }],
};
this.drawMachinesPercentageChart(machinesPercentageData);
}
},
drawLanguagePieChart(languagesPieData) {
return new this.Chart(`#languages-${this.chartId}`, {
title: 'Languages',
type: 'donut',
data: languagesPieData,
height: 250,
strokeWidth: 15,
tooltipOptions: {
formatTooltipY: d => showNumAsThousand(d),
},
});
},
drawXpHistoryChart(xpHistoryData) {
return new this.Chart(`#xp-history-${this.chartId}`, {
title: 'Historical XP',
type: 'heatmap',
data: {
dataPoints: xpHistoryData,
start: this.chartStartDate,
end: new Date(),
},
discreteDomains: 0,
radius: 2,
colors: ['#caf0f8', '#48cae4', '#0077b6', '#023e8a', '#090a79'],
});
},
drawMachinesPercentageChart(machineChartData) {
return new this.Chart(`#machines-${this.chartId}`, {
title: 'Machines',
type: 'percentage',
data: machineChartData,
height: 180,
strokeWidth: 15,
tooltipOptions: {
formatTooltipY: d => showNumAsThousand(d),
},
colors: ['#f9c80e', '#43bccd', '#ea3546', '#662e9b', '#f86624'],
});
},
/* Given a users XP score, return text level */
makeLevel(xp) {
if (xp < 100) return 'New Joiner';
if (xp < 1000) return 'Noob';
if (xp < 10000) return 'Intermediate';
if (xp < 50000) return 'Code ninja in the making';
if (xp < 100000) return 'Expert Developer';
if (xp < 500000) return 'Ultra Expert Developer';
if (xp < 1000000) return 'Code Super Hero';
if (xp < 1500000) return 'Super Epic Code Hero';
if (xp >= 15000000) return 'God Level';
return xp;
},
},
};
</script>
<style scoped lang="scss">
.code-stats-wrapper {
p {
margin: 0;
font-size: 1rem;
color: var(--widget-text-color);
}
.user-meta {
display: flex;
margin: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px dashed var(--widget-text-color);
justify-content: space-between;
.user-info-wrap {
.username {
font-size: 1.4rem;
text-transform: capitalize;
}
.user-level {
font-size: 0.8rem;
text-transform: capitalize;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
}
.total-xp-wrap {
display: flex;
align-items: flex-start;
.total-xp {
font-size: 1.4rem;
font-family: var(--font-monospace);
}
.new-xp {
font-size: 0.8rem;
margin: 0 0 0 0.5rem;
color: var(--success);
font-family: var(--font-monospace);
}
}
}
.xp-heat-chart,
.language-pie-chart,
.machine-percentage-chart {
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
}
</style>

View File

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

View File

@ -0,0 +1,178 @@
<template>
<div class="crypto-price-chart" :id="chartId"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm';
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
chartData: null,
chartDom: null,
};
},
computed: {
/* The crypto asset to fetch price data for */
asset() {
const userChoice = this.options.asset;
if (typeof userChoice === 'string') return userChoice;
return 'bitcoin';
},
/* Number of days worth of history to fetch and display */
numDays() {
const userChoice = this.options.numDays;
if (typeof usersChoice === 'number' && userChoice < 30 && userChoice > 0.15) {
return userChoice;
}
return 7;
},
/* The fiat currency to calculate price data in */
currency() {
const userChoice = this.options.currency;
if (typeof userChoice === 'string') return userChoice;
return 'USD';
},
/* The number of data points to render on the chart */
dataPoints() {
const userChoice = this.options.dataPoints;
if (typeof usersChoice === 'number' && userChoice < 100 && userChoice > 5) {
return userChoice;
}
return 30;
},
/* The formatted GET request API endpoint to fetch crypto data from */
endpoint() {
return `${widgetApiEndpoints.cryptoPrices}${this.asset}/`
+ `market_chart?vs_currency=${this.currency}&days=${this.numDays}`;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `crypto-price-chart-${Math.round(Math.random() * 10000)}`;
},
},
methods: {
/* Create new chart, using the crypto data */
generateChart() {
return new Chart(`#${this.chartId}`, {
title: `${this.asset} Price Chart`,
data: this.chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${d} ${this.currency}`,
},
});
},
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
try {
this.processData(response.data);
} catch (chartingError) {
this.error('Unable to plot results on chart', chartingError);
}
})
.catch((dataFetchError) => {
this.error('Unable to fetch crypto data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Generate price history in a format that can be consumed by the chart
* To improve efficiency, only a certain amount of data points are plotted
* depending on user preference. An average is then calculated between points
*/
processData(data) {
const priceChartData = [];
const priceLabels = [];
const interval = Math.round(data.prices.length / this.dataPoints);
const showTime = this.numDays < 5;
// Counters for calculating averages between data points
let tmpCounter = 0; let tmpTotal = 0;
const incrementAverage = (add) => {
tmpCounter += 1; tmpTotal += add;
if (add === null) { tmpCounter = 0; tmpTotal = 0; }
};
// For each data point, calc average, and if interval is right, then append
data.prices.forEach((priceGroup, index) => {
incrementAverage(priceGroup[1]); // Increment averages
if (index % interval === 0) {
const price = this.formatPrice(tmpTotal / tmpCounter);
priceLabels.push(this.formatDate(priceGroup[0], showTime));
priceChartData.push(price);
incrementAverage(null); // Reset counter
}
});
// Combine results with chart config
this.chartData = {
labels: priceLabels,
datasets: [
{
name: 'Price',
type: 'bar',
values: priceChartData,
},
],
};
// Call chart render function
this.renderChart();
},
/* Uses class data to render the line chart */
renderChart() {
this.chartDom = this.generateChart();
},
/* Format the date for a given time stamp, also include time if required */
formatDate(timestamp, includeTime) {
const localFormat = navigator.language;
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
const timeFormat = { hour: 'numeric', minute: 'numeric', second: 'numeric' };
const date = new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
const time = Intl.DateTimeFormat(localFormat, timeFormat).format(timestamp);
return `${date} ${includeTime ? time : ''}`;
},
/* Format the price, rounding to given number of decimal places */
formatPrice(price) {
let numDecimals = 0;
if (price < 10) numDecimals = 1;
if (price < 1) numDecimals = 2;
if (price < 0.1) numDecimals = 3;
if (price < 0.01) numDecimals = 4;
if (price < 0.001) numDecimals = 5;
return price.toFixed(numDecimals);
},
},
};
</script>
<style lang="scss">
.crypto-price-chart .chart-container {
text.title {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="wallet-balance">
<template v-if="cryptoData">
<div
v-for="(asset, index) in cryptoData"
:key="index"
class="asset-wrapper"
v-tooltip="tooltip(asset.info)"
>
<img class="icon" :src="asset.image" :alt="`${asset} icon`" />
<p class="name">{{ asset.name }}</p>
<p class="price">{{ asset.price | formatPrice }}</p>
<p :class="`percent ${asset.percentChange > 0 ? 'up' : 'down'}`">
{{ asset.percentChange | formatPercentage }}
</p>
</div>
</template>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import {
findCurrencySymbol, timestampToDate, roundPrice, putCommasInBigNum,
} from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
cryptoData: null,
};
},
computed: {
/* The crypto assets to fetch price data for */
assets() {
const usersChoice = this.options.assets;
if (!usersChoice) return '';
return usersChoice.join(',');
},
/* The fiat currency to calculate price data in */
currency() {
const userChoice = this.options.currency;
if (typeof userChoice === 'string') return userChoice;
return 'USD';
},
limit() {
const userChoice = this.options.limit;
if (userChoice && userChoice > 0) return userChoice;
return 100;
},
/* How results should be sorted */
order() {
const userChoice = this.options.sortBy;
switch (userChoice) {
case ('alphabetical'): return 'id_asc';
case ('volume'): return 'volume_desc';
case ('marketCap'): return 'market_cap_desc';
default: return 'market_cap_desc';
}
},
/* The formatted GET request API endpoint to fetch crypto data from */
endpoint() {
return `${widgetApiEndpoints.cryptoWatchList}?`
+ `ids=${this.assets}&vs_currency=${this.currency}&order=${this.order}&per_page=${this.limit}`;
},
},
filters: {
/* Append currency symbol to price */
formatPrice(price) {
return `${findCurrencySymbol('usd')}${putCommasInBigNum(roundPrice(price))}`;
},
/* Append percentage symbol, and up/ down arrow */
formatPercentage(change) {
if (!change) return '';
const symbol = change > 0 ? '↑' : '↓';
return `${symbol} ${change.toFixed(2)}%`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((error) => {
this.error('Unable to fetch crypto watch list', error);
})
.finally(() => {
this.finishLoading();
});
},
/* Convert response data into JSON to be consumed by the UI */
processData(data) {
const results = [];
data.forEach((token) => {
results.push({
name: token.name,
image: token.image,
price: token.current_price,
percentChange: token.price_change_percentage_24h,
info: {
symbol: token.symbol,
rank: token.market_cap_rank,
marketCap: token.market_cap,
supply: token.circulating_supply,
maxSupply: token.max_supply,
allTimeHigh: token.ath,
allTimeHighDate: token.ath_date,
},
});
});
this.cryptoData = results;
},
/* Show additional info as a tooltip on hover */
tooltip(info) {
const maxSupply = info.maxSupply ? ` out of max supply of <b>${info.maxSupply}</b>` : '';
const content = `Rank: <b>${info.rank}</b> with market cap of `
+ `<b>${this.$options.filters.formatPrice(info.marketCap)}</b>`
+ `<br>Circulating Supply: <b>${info.supply} ${info.symbol.toUpperCase()}</b>${maxSupply}`
+ `<br>All-time-high of <b>${info.allTimeHigh}</b> `
+ `at <b>${timestampToDate(info.allTimeHighDate)}</b>`;
return {
content, html: true, trigger: 'hover focus', delay: 250,
};
},
},
};
</script>
<style scoped lang="scss">
.asset-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--widget-text-color);
font-size: 0.9rem;
.icon {
width: 2rem;
height: 2rem;
border-radius: 1rem;
}
.name {
font-weight: bold;
}
.percent, .price {
font-family: var(--font-monospace);
&.up { color: var(--success); }
&.down { color: var(--danger); }
}
p {
width: 28%;
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
</style>

View File

@ -0,0 +1,236 @@
<template>
<div class="cve-wrapper" v-if="cveList">
<div v-for="cve in cveList" :key="cve.id" class="cve-row">
<a class="upper" :href="cve.url" target="_blank">
<p :class="`score ${makeScoreColor(cve.score)}`">{{ cve.score }}</p>
<div class="title-wrap">
<p class="title">{{ cve.id }}</p>
<span class="date">{{ cve.publishDate | formatDate }}</span>
<span class="last-updated">Last Updated: {{ cve.updateDate | formatDate }}</span>
<span :class="`exploit-count ${makeExploitColor(cve.numExploits)}`">
{{ cve.numExploits | formatExploitCount }}
</span>
</div>
</a>
<p class="description">
{{ cve.description | formatDescription }}
<a v-if="cve.description.length > 350" class="read-more" :href="cve.url" target="_blank">
{{ $t('widgets.general.open-link') }}
</a>
</p>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { timestampToDate, truncateStr } from '@/utils/MiscHelpers';
import { widgetApiEndpoints, serviceEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
cveList: null,
};
},
filters: {
formatDate(date) {
return timestampToDate(date);
},
formatDescription(description) {
return truncateStr(description, 350);
},
formatExploitCount(numExploits) {
if (!numExploits) return 'Number of exploits not known';
if (numExploits === '0') return 'No published exploits';
return `${numExploits} known exploit${numExploits !== '1' ? 's' : ''}`;
},
},
computed: {
/* Get sort order, defaults to publish date */
sortBy() {
const usersChoice = this.options.sortBy;
let sortCode;
switch (usersChoice) {
case ('publish-date'): sortCode = 1; break;
case ('last-update'): sortCode = 2; break;
case ('cve-code'): sortCode = 3; break;
default: sortCode = 1;
}
return `&orderby=${sortCode}`;
},
/* The minimum CVE score to fetch/ show, defaults to 4 */
minScore() {
const usersChoice = this.options.minScore;
let minScoreVal = 4;
if (usersChoice && (usersChoice >= 0 || usersChoice <= 10)) {
minScoreVal = usersChoice;
}
return `&cvssscoremin=${minScoreVal}`;
},
vendorId() {
return (this.options.vendorId) ? `&vendor_id=${this.options.vendorId}` : '';
},
productId() {
return (this.options.productId) ? `&product_id=${this.options.productId}` : '';
},
/* Should only show results with exploits, defaults to false */
hasExploit() {
const shouldShow = this.options.hasExploit ? 1 : 0;
return `&hasexp=${shouldShow}`;
},
/* The number of results to fetch/ show, defaults to 10 */
limit() {
const usersChoice = this.options.limit;
let numResults = 10;
if (usersChoice && (usersChoice >= 5 || usersChoice <= 30)) {
numResults = usersChoice;
}
return `&numrows=${numResults}`;
},
endpoint() {
return `${widgetApiEndpoints.cveVulnerabilities}?${this.sortBy}${this.limit}`
+ `${this.minScore}${this.vendorId}${this.hasExploit}`;
},
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.request({
method: 'GET',
url: this.proxyReqEndpoint,
headers: { 'Target-URL': this.endpoint },
})
.then((response) => {
this.processData(response.data);
}).catch((error) => {
this.error('Unable to fetch CVE data', error);
}).finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const cveList = [];
data.forEach((cve) => {
cveList.push({
id: cve.cve_id,
score: cve.cvss_score,
url: cve.url,
description: cve.summary,
numExploits: cve.exploit_count,
publishDate: cve.publish_date,
updateDate: cve.update_date,
});
});
this.cveList = cveList;
},
makeExploitColor(numExploits) {
if (!numExploits || Number.isNaN(parseInt(numExploits, 10))) return 'fg-grey';
const count = parseInt(numExploits, 10);
if (count === 0) return 'fg-green';
if (count === 1) return 'fg-orange';
if (count > 1) return 'fg-red';
return 'fg-grey';
},
makeScoreColor(inputScore) {
if (!inputScore || Number.isNaN(parseFloat(inputScore))) return 'bg-grey';
const score = parseFloat(inputScore);
if (score >= 9) return 'bg-red';
if (score >= 7) return 'bg-orange';
if (score >= 4) return 'bg-yellow';
if (score >= 0.1) return 'bg-green';
return 'bg-blue';
},
},
};
</script>
<style scoped lang="scss">
.cve-wrapper {
.cve-row {
p, span, a {
font-size: 1rem;
margin: 0.5rem 0;
color: var(--widget-text-color);
&.bg-green { background: var(--success); }
&.bg-yellow { background: var(--warning); }
&.bg-orange { background: var(--error); }
&.bg-red { background: var(--danger); }
&.bg-blue { background: var(--info); }
&.bg-grey { background: var(--neutral); }
&.fg-green { color: var(--success); }
&.fg-yellow { color: var(--warning); }
&.fg-orange { color: var(--error); }
&.fg-red { color: var(--danger); }
&.fg-blue { color: var(--info); }
&.fg-grey { color: var(--neutral); }
}
a.upper {
display: flex;
margin: 0.25rem 0 0 0;
align-items: center;
text-decoration: none;
}
.score {
font-size: 1.1rem;
font-weight: bold;
padding: 0.45rem 0.25rem 0.25rem 0.25rem;
margin-right: 0.5rem;
border-radius: 30px;
font-family: var(--font-monospace);
background: var(--widget-text-color);
color: var(--widget-background-color);
}
.title {
font-family: var(--font-monospace);
font-size: 1.2rem;
font-weight: bold;
margin: 0;
}
.date, .last-updated {
font-size: 0.8rem;
margin: 0;
opacity: var(--dimming-factor);
padding-right: 0.5rem;
}
.exploit-count {
display: none;
font-size: 0.8rem;
margin: 0;
padding-left: 0.5rem;
opacity: var(--dimming-factor);
border-left: 1px solid var(--widget-text-color);
}
.seperator {
font-size: 0.8rem;
margin: 0;
opacity: var(--dimming-factor);
}
.description {
margin: 0 0.25rem 0.5rem 0.25rem;
}
a.read-more {
opacity: var(--dimming-factor);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
.last-updated {
display: none;
}
&:hover {
.date { display: none; }
.exploit-count, .last-updated { display: inline; }
}
}
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<div class="html-widget">
<div :id="elementId" />
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
computed: {
/* Optional HTML markup to be rendered */
html() {
return this.options.html || '';
},
/* Optional CSS styles to be applied */
css() {
return this.options.css || '';
},
/* Optional raw JavaScript to be executed */
script() {
return this.options.script || '';
},
/* Optional path to JS script to be fetched */
scriptSrc() {
return this.options.scriptSrc || '';
},
/* Unique element ID */
elementId() {
return `elem-${Math.round(Math.random() * 10000)}`;
},
},
mounted() {
this.initiate();
},
beforeDestroy() {
window.removeEventListener('load', this.injectHtml);
},
methods: {
/* Injects users content */
injectHtml() {
if (this.html) {
const element = document.getElementById(this.elementId);
element.innerHTML = this.html;
}
if (this.css) {
const styleElem = document.createElement('style');
styleElem.textContent = this.css;
document.head.append(styleElem);
}
if (this.script) {
const scriptElem = document.createElement('script');
scriptElem.text = this.script;
document.head.append(scriptElem);
}
if (this.scriptSrc) {
const scriptElem = document.createElement('script');
scriptElem.src = this.scriptSrc;
document.head.append(scriptElem);
}
},
/* What for the DOM to finish loading, before proceeding */
initiate() {
if (document.readyState === 'complete' || document.readyState === 'loaded') {
this.injectHtml();
} else {
window.addEventListener('load', this.injectHtml);
}
},
update() {
this.injectHtml();
},
},
};
</script>
<style scoped lang="scss">
.html-widget {
width: 100%;
min-height: 240px;
}
</style>

View File

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

View File

@ -0,0 +1,177 @@
<template>
<div class="exchange-rate-wrapper">
<template v-if="exchangeRates">
<p class="exchange-base-currency">Value of 1 {{ newInputCurrency || inputCurrency }}</p>
<p class="reset" v-if="newInputCurrency" @click="updateInputCurrency(inputCurrency)">
Reset back to {{ inputCurrency }}
</p>
<div
v-for="(exchange, index) in exchangeRates" :key="index"
v-tooltip="tooltip(makeInverse(exchange))"
class="exchange-rate-row"
>
<p class="country" @click="updateInputCurrency(exchange.currency)">
<img :src="exchange.currency | flagUrl" alt="Flag" class="flag" />
{{ exchange.currency }}
</p>
<p class="value">
<span class="input-currency">
{{ 1 | applySymbol(newInputCurrency || inputCurrency) }} =
</span>
{{ exchange.value | applySymbol(exchange.currency) }}
</p>
</div>
<p class="last-updated">Updated on {{ lastUpdated }}</p>
</template>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { findCurrencySymbol, getCurrencyFlag, timestampToDate } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
exchangeRates: null,
newInputCurrency: null,
lastUpdated: null,
};
},
computed: {
/* The users API key for exchangerate-api.com */
apiKey() {
return this.options.apiKey;
},
/* The currency to convert results into */
inputCurrency() {
return this.options.inputCurrency || 'USD';
},
/* An array of currencies to display */
outputCurrencies() {
return this.options.outputCurrencies || [];
},
endpoint() {
const currency = this.newInputCurrency || this.inputCurrency;
return `${widgetApiEndpoints.exchangeRates}${this.apiKey}/latest/${currency}`;
},
},
filters: {
/* Appends currency symbol onto price */
applySymbol(price, inputCurrency) {
return `${findCurrencySymbol(inputCurrency)}${price}`;
},
flagUrl(currency) {
return getCurrencyFlag(currency);
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then(response => {
this.processData(response.data);
}).catch(error => {
this.error('Unable to fetch or process exchange rate data', error);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const results = [];
const rates = data.conversion_rates;
Object.keys(rates).forEach((currency) => {
if (this.outputCurrencies.includes(currency)) {
results.push({ currency, value: rates[currency] });
}
});
this.exchangeRates = results;
this.lastUpdated = timestampToDate(data.time_last_update_unix * 1000);
},
updateInputCurrency(newCurrency) {
this.startLoading();
if (newCurrency === this.inputCurrency) {
this.newInputCurrency = null;
} else {
this.newInputCurrency = newCurrency;
}
this.fetchData();
},
makeInverse(exchange) {
return `1 ${exchange.currency} = ${(1 / exchange.value).toFixed(2)}`
+ ` ${this.newInputCurrency || this.inputCurrency}`;
},
},
};
</script>
<style scoped lang="scss">
.exchange-rate-wrapper {
max-width: 380px;
margin: 0 auto;
p.exchange-base-currency {
margin: 0.25rem 0;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
p.reset {
opacity: var(--dimming-factor);
color: var(--widget-text-color);
margin: 0.25rem 0;
font-size: 0.8rem;
text-decoration: underline;
cursor: pointer;
&:hover { opacity: 1; }
}
.exchange-rate-row {
display: flex;
justify-content: space-between;
margin: 0.25rem auto;
padding: 0.25rem;
p {
margin: 0;
color: var(--widget-text-color);
}
p.country {
cursor: pointer;
display: flex;
align-items: center;
img.flag {
border-radius: var(--curve-factor);
margin-right: 0.5rem;
max-width: 40px;
}
}
p.value {
display: flex;
align-items: center;
font-family: var(--font-monospace);
span.input-currency {
display: none;
opacity: var(--dimming-factor);
font-size: 0.8rem;
margin-right: 0.5rem;
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
&:hover {
p.value span.input-currency { display: block; }
}
}
p.last-updated {
opacity: var(--dimming-factor);
color: var(--widget-text-color);
font-family: var(--font-monospace);
margin: 0.2rem 0;
font-size: 0.6rem;
}
}
</style>

View File

@ -0,0 +1,189 @@
<template>
<div class="flight-wrapper">
<!-- Info -->
<p class="flight-intro">
Live {{ direction !== 'both' ? direction: 'flight' }} data from {{ airport }}
</p>
<!-- Departures -->
<div v-if="departures.length > 0" class="flight-group">
<h3 class="flight-type-subtitle" v-if="direction === 'both'">
{{ $t('widgets.flight-data.departures') }}
</h3>
<div v-for="flight in departures" :key="flight.number" class="flight" v-tooltip="tip(flight)">
<p class="info flight-time">{{ flight.time | formatDate }}</p>
<p class="info flight-number">{{ flight.number }}</p>
<p class="info flight-airport">{{ flight.airport }}</p>
</div>
</div>
<!-- Arrivals -->
<div v-if="arrivals.length > 0" class="flight-group">
<h3 class="flight-type-subtitle" v-if="direction === 'both'">
{{ $t('widgets.flight-data.arrivals') }}
</h3>
<div v-for="flight in arrivals" :key="flight.number" class="flight" v-tooltip="tip(flight)">
<p class="info flight-time">{{ flight.time | formatDate }}</p>
<p class="info flight-number">{{ flight.number }}</p>
<p class="info flight-airport">{{ flight.airport }}</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
departures: [],
arrivals: [],
};
},
filters: {
formatDate(date) {
const d = new Date(date);
if (Number.isNaN(d.getHours())) return '[UNKNOWN]';
return `${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`;
},
},
computed: {
/* The users desired airport, specified as a 4-digit ICAO-code */
airport() {
const usersChoice = this.options.airport;
if (!usersChoice) {
this.error('A valid airport must be specified');
return '';
}
const formattedAirport = usersChoice.toUpperCase().trim();
if (!(/[A-Z]{4}/).test(formattedAirport)) {
this.error('Incorrect airport format, must be a valid 4-digit ICAO-code');
return '';
}
return formattedAirport;
},
apiKey() {
const usersChoice = this.options.apiKey;
if (!usersChoice) {
this.error('An API key must be supplied');
return '';
}
return usersChoice;
},
/* The direction of flights: Arrival, Departure or Both */
direction() {
const usersChoice = this.options.direction;
if (!usersChoice || typeof usersChoice !== 'string') return 'both';
const options = ['arrival', 'departure', 'both'];
if (options.includes(usersChoice.toLowerCase())) return usersChoice;
return 'both';
},
limit() {
const usersChoice = this.options.limit;
if (usersChoice) return usersChoice;
return 8;
},
/* The starting date, right now, in ISO String format */
fromDate() {
const now = new Date();
return new Date(`${now.toString().split('GMT')[0]} UTC`).toISOString().split('.')[0];
},
/* The ending date, 12 hours from now, in ISO string format */
toDate() {
const now = new Date(new Date().setSeconds(0));
const tomorrow = new Date(new Date(now).setHours(now.getHours() + 12));
return new Date(`${tomorrow.toString().split('GMT')[0]} UTC`).toISOString().split('.')[0];
},
endpoint() {
return `${widgetApiEndpoints.flights}${this.airport}/${this.fromDate}/${this.toDate}`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
const requestConfig = {
method: 'GET',
url: this.endpoint,
params: {
withCargo: 'true',
withPrivate: 'true',
withLocation: 'false',
},
headers: {
'x-rapidapi-host': 'aerodatabox.p.rapidapi.com',
'x-rapidapi-key': this.apiKey,
},
};
axios.request(requestConfig)
.then((response) => {
this.processData(response.data);
}).catch((error) => {
this.error('Unable to fetch flight data', error);
}).finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
this.arrivals = this.makeFlightList(data.arrivals).slice(0, this.limit);
this.departures = this.makeFlightList(data.departures).slice(0, this.limit);
},
/* Gets the useful flight info out of departures or arrivals */
makeFlightList(flights) {
const results = [];
flights.forEach((flight) => {
results.push({
number: flight.number,
airline: flight.airline.name,
aircraft: flight.aircraft.model,
airport: flight.movement.airport.name,
time: flight.movement.actualTimeUtc,
});
});
return results;
},
tip(flight) {
const content = `${flight.aircraft} | ${flight.airline}`;
return {
content, trigger: 'hover focus', delay: 250, classes: 'in-modal-tt',
};
},
},
};
</script>
<style scoped lang="scss">
.flight-wrapper {
p.flight-intro {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
h3.flight-type-subtitle {
margin: 0.25rem 0;
font-size: 1.2rem;
color: var(--widget-text-color);
}
.flight-group {
.flight {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0.25rem 0;
p.info {
margin: 0;
min-width: 33%;
color: var(--widget-text-color);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<div class="readme-stats">
<img class="stats-card" v-if="!hideProfileCard" :src="profileCard" alt="Profile Card" />
<img class="stats-card" v-if="!hideLanguagesCard" :src="topLanguagesCard" alt="Languages" />
<template v-if="repos">
<img class="stats-card" v-for="(repo, i) in repoCards" :key="i" :src="repo" :alt="repo" />
</template>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
computed: {
hideProfileCard() {
return this.options.hideProfileCard;
},
hideLanguagesCard() {
return this.options.hideLanguagesCard;
},
username() {
const usersChoice = this.options.username;
if ((!this.hideProfileCard || !this.hideLanguagesCard) && !usersChoice) {
this.error('You must specify a GitHub username');
}
return usersChoice;
},
repos() {
const usersChoice = this.options.repos;
if (!usersChoice) return null;
if (typeof usersChoice === 'string') return [usersChoice];
if (Array.isArray(usersChoice)) return usersChoice;
this.error('Invalid format for repositories input');
return null;
},
colors() {
const cssVars = getComputedStyle(document.documentElement);
const getColor = (colorVar) => cssVars.getPropertyValue(`--${colorVar}`).trim().replace('#', '');
const primary = getColor('widget-text-color') || '7cd6fd';
const accent = getColor('widget-accent-color') || '7cd6fd';
const background = getColor('widget-background-color') || '7cd6fd';
const radius = getColor('curve-factor').replace('px', '') || '6';
const white = getColor('white') || 'fff';
return {
primary, accent, background, white, radius,
};
},
locale() {
if (this.options.lang) return this.options.lang;
return this.$store.getters.appConfig.lang || 'en';
},
cardConfig() {
const c = this.colors;
return `&title_color=${c.primary}&text_color=${c.white}&icon_color=${c.primary}`
+ `&bg_color=${c.background}&border_radius=${c.radius}&locale=${this.locale}`
+ '&count_private=true&show_icons=true&hide_border=true';
},
profileCard() {
return `${widgetApiEndpoints.readMeStats}?username=${this.username}${this.cardConfig}`;
},
topLanguagesCard() {
return `${widgetApiEndpoints.readMeStats}/top-langs/?username=${this.username}`
+ `${this.cardConfig}&langs_count=12`;
},
repoCards() {
const cards = [];
this.repos.forEach((repo) => {
const username = repo.split('/')[0];
const repoName = repo.split('/')[1];
cards.push(`${widgetApiEndpoints.readMeStats}/pin/?username=${username}&repo=${repoName}`
+ `${this.cardConfig}&show_owner=true`);
});
return cards;
},
},
};
</script>
<style scoped lang="scss">
.readme-stats {
img.stats-card {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,157 @@
<template>
<div class="trending-repos-wrapper" v-if="trendingRepos">
<div v-for="repo in trendingRepos" :key="repo.idx" class="repo-row">
<img class="repo-img" v-if="repo.avatar" :src="repo.avatar" alt="Repo" />
<div class="repo-info">
<p class="repo-name">{{ repo.name }}</p>
<div class="star-wrap">
<p class="all-stars" v-if="repo.stars">{{ repo.stars | formatStars }}</p>
<p class="new-stars" v-if="repo.newStars">{{ repo.newStars | formatStars }}</p>
</div>
<a class="repo-link" :href="repo.link">{{ repo.slug }}</a>
<p class="repo-desc">{{ repo.desc }}</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { capitalize, showNumAsThousand } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
data() {
return {
trendingRepos: null,
};
},
filters: {
formatStars(starCount) {
if (!starCount) return null;
const numericCount = typeof starCount === 'string'
? parseInt(starCount.replaceAll(',', ''), 10) : starCount;
return `${showNumAsThousand(numericCount) || starCount}`;
},
},
computed: {
since() {
const usersChoice = this.options.since;
const options = ['daily', 'weekly', 'monthly'];
if (usersChoice && options.includes(usersChoice)) return usersChoice;
return options[0];
},
lang() {
return this.options.lang || '';
},
limit() {
return this.options.limit || 10;
},
endpoint() {
return `${widgetApiEndpoints.githubTrending}repo?since=${this.since}&lang=${this.lang}`;
},
},
methods: {
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (response.data.items) {
this.processData(response.data.items);
}
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
processData(repos) {
const mkeName = (r) => capitalize(r.split('/')[1].replaceAll('-', ' ').replaceAll('_', ' '));
let results = [];
repos.forEach((repo) => {
results.push({
name: mkeName(repo.repo),
slug: repo.repo,
desc: repo.desc,
lang: repo.lang,
link: repo.repo_link,
stars: repo.stars,
forks: repo.forks,
newStars: parseInt(repo.added_stars, 10),
avatar: repo.avatars[0] || 'https://github.com/fluidicon.png',
});
});
if (this.limit && this.limit < results.length) {
results = results.slice(0, this.limit);
}
this.trendingRepos = results;
},
},
};
</script>
<style scoped lang="scss">
.trending-repos-wrapper {
.repo-row {
display: flex;
align-items: center;
margin: 0.5rem 0;
cursor: default;
img.repo-img {
width: 2.5rem;
border-radius: var(--curve-factor-small);
}
.repo-info {
display: grid;
width: 100%;
grid-template-columns: auto 1fr;
padding-left: 0.5rem;
p.repo-name {
margin: 0.1rem 0;
font-size: 1.2rem;
font-weight: bold;
color: var(--widget-text-color);
}
a.repo-link {
margin: 0.1rem 0;
font-size: 0.8rem;
text-decoration: underline;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
p.repo-desc {
grid-column-start: span 2;
margin: 0.1rem 0 0.25rem;
font-size: 0.8rem;
color: var(--widget-text-color);
}
.star-wrap {
grid-row-start: span 2;
min-width: 3rem;
text-align: right;
p {
font-family: var(--font-monospace);
margin: 0;
&.all-stars {
color: var(--widget-text-color);
font-size: 1.2rem;
font-weight: bold;
}
&.new-stars {
font-size: 0.8rem;
color: var(--success);
opacity: var(--dimming-factor);
}
}
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,144 @@
<template>
<div class="health-checks-wrapper" v-if="crons">
<div
class="cron-row"
v-for="cron in crons" :key="cron.id"
v-tooltip="pingTimeTooltip(cron)"
>
<div class="status">
<p :class="cron.status">{{ cron.status | formatStatus }}</p>
</div>
<div class="info">
<p class="cron-name">{{ cron.name }}</p>
<p class="cron-desc">{{ cron.desc }}</p>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { capitalize, timestampToDateTime } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
crons: null,
};
},
filters: {
formatStatus(status) {
let symbol = '';
if (status === 'up') symbol = '✔';
if (status === 'down') symbol = '✘';
if (status === 'new') symbol = '❖';
return `${symbol} ${capitalize(status)}`;
},
formatDate(timestamp) {
return timestampToDateTime(timestamp);
},
},
computed: {
/* API endpoint, either for self-hosted or managed instance */
endpoint() {
if (this.options.host) return `${this.options.host}/api/v1/checks`;
return `${widgetApiEndpoints.healthChecks}`;
},
apiKey() {
if (!this.options.apiKey) {
this.error('An API key is required, please see the docs for more info');
}
return this.options.apiKey;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
this.overrideProxyChoice = true;
const authHeaders = { 'X-Api-Key': this.apiKey };
this.makeRequest(this.endpoint, authHeaders).then(
(response) => { this.processData(response); },
);
},
/* Assign data variables to the returned data */
processData(data) {
const results = [];
data.checks.forEach((cron) => {
results.push({
id: cron.slug,
name: cron.name,
desc: cron.desc,
status: cron.status,
pingCount: cron.n_pings,
lastPing: cron.last_ping,
nextPing: cron.next_ping,
url: this.makeUrl(cron.unique_key),
});
});
this.crons = results;
},
makeUrl(cronId) {
const base = this.options.host || 'https://healthchecks.io';
return `${base}/checks/${cronId}/details`;
},
pingTimeTooltip(cron) {
const { lastPing, nextPing, pingCount } = cron;
const content = `<b>Total number of Pings:</b> ${pingCount}<br>`
+ `<b>Last Ping:</b> ${timestampToDateTime(lastPing)}<br>`
+ `<b>Next Ping:</b>${timestampToDateTime(nextPing)}`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'ping-times-tt',
};
},
},
};
</script>
<style scoped lang="scss">
.health-checks-wrapper {
color: var(--widget-text-color);
.cron-row {
display: flex;
justify-content: center;
align-items: center;
padding: 0.25rem 0;
.status {
min-width: 5rem;
font-size: 1.2rem;
font-weight: bold;
p {
margin: 0;
color: var(--info);
&.up { color: var(--success); }
&.down { color: var(--danger); }
&.new { color: var(--neutral); }
}
}
.info {
p.cron-name {
margin: 0.25rem 0;
font-weight: bold;
color: var(--widget-text-color);
}
p.cron-desc {
margin: 0;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>
<style lang="scss">
.ping-times-tt {
min-width: 20rem;
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<div class="iframe-widget">
<iframe
v-if="frameUrl"
:src="frameUrl"
:id="frameId"
title="Iframe Widget"
:style="frameHeight ? `height: ${frameHeight}px` : ''"
/>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
computed: {
/* Gets users specified URL to load into the iframe */
frameUrl() {
const usersChoice = this.options.url;
if (!usersChoice || typeof usersChoice !== 'string') {
this.error('Iframe widget expects a URL');
return null;
}
return usersChoice;
},
frameHeight() {
return this.options.frameHeight;
},
/* Generates an ID for the iframe */
frameId() {
return `iframe-${btoa(this.frameUrl || 'empty').substring(0, 16)}`;
},
},
methods: {
/* Refreshes iframe contents, called by parent */
update() {
this.startLoading();
(document.getElementById(this.frameId) || {}).src = this.frameUrl;
this.finishLoading();
},
},
};
</script>
<style scoped lang="scss">
.iframe-widget {
iframe {
width: 100%;
min-height: 240px;
border: 0;
}
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<div v-if="jokeType" class="joke-wrapper">
<p class="joke joke-line-1">{{ jokeLine1 }}</p>
<p class="joke joke-line-2" v-if="jokeLine2">{{ jokeLine2 }}</p>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
jokeType: null,
jokeLine1: null,
jokeLine2: null,
};
},
computed: {
/* Language code to fetch jokes for */
language() {
const supportedLanguages = ['en', 'cs', 'de', 'es', 'fr', 'pt'];
const usersChoice = this.options.language;
if (usersChoice && supportedLanguages.includes(usersChoice)) return usersChoice;
const localLanguage = this.$store.getters.appConfig.lang;
if (localLanguage && supportedLanguages.includes(localLanguage)) return localLanguage;
return 'en';
},
/* Should enable safe mode, to disallow NSFW jokes */
safeMode() {
return !!this.options.safeMode;
},
/* Format the users preferred category */
category() {
let usersChoice = this.options.category;
if (!usersChoice) return 'any';
if (Array.isArray(usersChoice)) usersChoice = usersChoice.join();
const categories = ['any', 'misc', 'programming', 'dark', 'pun', 'spooky', 'christmas'];
if (categories.some((cat) => usersChoice.toLowerCase().includes(cat))) return usersChoice;
return 'any';
},
/* Combine data parameters for the API endpoint */
endpoint() {
return `${widgetApiEndpoints.jokes}${this.category}`
+ `?lang=${this.language}${this.safeMode ? '&safe-mode' : ''}`;
},
},
methods: {
/* Make GET request to Jokes API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (response.data.error) {
this.error('No matching jokes returned', response.data.additionalInfo);
}
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch any jokes', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
this.jokeType = data.type;
if (this.jokeType === 'twopart') {
this.jokeLine1 = data.setup;
this.jokeLine2 = data.delivery;
} else if (this.jokeType === 'single') {
this.jokeLine1 = data.joke;
}
},
},
};
</script>
<style scoped lang="scss">
.joke-wrapper {
p.joke {
color: var(--widget-text-color);
font-size: 1.2rem;
}
}
</style>

View File

@ -0,0 +1,108 @@
<template>
<div class="cpu-history-chart" :id="chartId"></div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
computed: {
/* URL where NetData is hosted */
netDataHost() {
const usersChoice = this.options.host;
if (!usersChoice || typeof usersChoice !== 'string') {
this.error('Host parameter is required');
return '';
}
return usersChoice;
},
apiVersion() {
return this.options.apiVersion || 'v1';
},
endpoint() {
return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.cpu`;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `cpu-history-chart-${Math.round(Math.random() * 10000)}`;
},
},
methods: {
/* Make GET request to NetData */
fetchData() {
this.makeRequest(this.endpoint).then(
(response) => { this.processData(response); },
);
},
/* Assign data variables to the returned data */
processData(inputData) {
const { labels, data } = inputData;
const timeData = []; // List of timestamps for axis
const resultGroup = {}; // List of datasets, for each label
data.reverse().forEach((reading) => {
labels.forEach((label, indx) => {
if (indx === 0) { // First value is the timestamp, add to axis
timeData.push(this.formatTime(reading[indx] * 1000));
} else { // All other values correspond to a label
if (!resultGroup[label]) resultGroup[label] = [];
resultGroup[label].push(reading[indx]);
}
});
});
const datasets = [];
Object.keys(resultGroup).forEach((label) => {
datasets.push({ name: label, type: 'bar', values: resultGroup[label] });
});
const timeChartData = { labels: timeData, datasets };
const chartTitle = this.makeChartTitle(data);
this.generateChart(timeChartData, chartTitle);
},
makeChartTitle(data) {
const prefix = this.$t('widgets.net-data.cpu-chart-title');
if (!data || !data[0][0]) return prefix;
const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60);
return `${prefix}: Past ${diff} minutes`;
},
/* Create new chart, using the crypto data */
generateChart(timeChartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, {
title: chartTitle,
data: timeChartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}%`,
},
});
},
},
};
</script>
<style lang="scss">
.cpu-history-chart .chart-container {
text.title {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

View File

@ -0,0 +1,116 @@
<template>
<div class="load-history-chart" :id="chartId"></div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
computed: {
/* URL where NetData is hosted */
netDataHost() {
const usersChoice = this.options.host;
if (!usersChoice || typeof usersChoice !== 'string') {
this.error('Host parameter is required');
return '';
}
return usersChoice;
},
apiVersion() {
return this.options.apiVersion || 'v1';
},
endpoint() {
return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.load`;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `cpu-history-chart-${Math.round(Math.random() * 10000)}`;
},
},
methods: {
/* Make GET request to NetData */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const timeData = [];
const load1min = [];
const load5mins = [];
const load15mins = [];
data.data.reverse().forEach((reading) => {
timeData.push(this.formatDate(reading[0] * 1000));
load1min.push(reading[1]);
load5mins.push(reading[2]);
load15mins.push(reading[3]);
});
const chartData = {
labels: timeData,
datasets: [
{ name: '1 Min', type: 'bar', values: load1min },
{ name: '5 Mins', type: 'bar', values: load5mins },
{ name: '15 Mins', type: 'bar', values: load15mins },
],
};
const chartTitle = this.makeChartTitle(data.data);
this.generateChart(chartData, chartTitle);
},
makeChartTitle(data) {
const prefix = this.$t('widgets.net-data.load-chart-title');
if (!data || !data[0][0]) return prefix;
const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60);
return `${prefix}: Past ${diff} minutes`;
},
/* Create new chart, using the crypto data */
generateChart(chartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, {
title: chartTitle,
data: chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}%`,
},
});
},
},
};
</script>
<style lang="scss">
.load-history-chart .chart-container {
text.title {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

View File

@ -0,0 +1,130 @@
<template>
<div class="memory-charts-wrapper">
<div class="chart" :id="`aggregate-${chartId}`"></div>
<div class="chart" :id="chartId"></div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
computed: {
/* URL where NetData is hosted */
netDataHost() {
const usersChoice = this.options.host;
if (!usersChoice || typeof usersChoice !== 'string') {
this.error('Host parameter is required');
return '';
}
return usersChoice;
},
apiVersion() {
return this.options.apiVersion || 'v1';
},
endpoint() {
return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.ram`;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `cpu-history-chart-${Math.round(Math.random() * 10000)}`;
},
},
methods: {
/* Make GET request to NetData */
fetchData() {
this.makeRequest(this.endpoint).then(
(response) => { this.processData(response); },
);
},
/* Assign data variables to the returned data */
processData(inputData) {
const { labels, data } = inputData;
// Convert data to an object for easy working
const timeData = []; // List of timestamps for axis
const resultGroup = {}; // List of datasets, for each label
data.reverse().forEach((reading) => {
labels.forEach((label, indx) => {
if (indx === 0) { // First value is the timestamp, add to axis
timeData.push(this.formatTime(reading[indx] * 1000));
} else { // All other values correspond to a label
if (!resultGroup[label]) resultGroup[label] = [];
resultGroup[label].push(reading[indx]);
}
});
});
// Put data in the format expected by the charts
const averages = [];
const datasets = [];
Object.keys(resultGroup).forEach((label) => {
datasets.push({ name: label, type: 'bar', values: resultGroup[label] });
averages.push(Math.round(this.average(resultGroup[label])));
});
// Set results as component attributes, and call to render
const timeChartData = { labels: timeData, datasets };
const aggregateChartData = { labels: labels.slice(1), datasets: [{ values: averages }] };
this.renderCharts(timeChartData, aggregateChartData);
},
renderCharts(timeChartData, aggregateChartData) {
this.generateHistoryChart(timeChartData);
this.generateAggregateChart(aggregateChartData);
},
/* Create new chart, using the crypto data */
generateHistoryChart(timeChartData) {
return new this.Chart(`#${this.chartId}`, {
title: this.$t('widgets.net-data.mem-chart-title'),
data: timeChartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}mb`,
},
});
},
generateAggregateChart(aggregateChartData) {
return new this.Chart(`#aggregate-${this.chartId}`, {
title: this.$t('widgets.net-data.mem-breakdown-title'),
data: aggregateChartData,
type: 'percentage',
height: 100,
colors: this.chartColors,
barOptions: {
height: 18,
depth: 5,
},
});
},
},
};
</script>
<style lang="scss">
.memory-charts-wrapper .chart {
text.title, text.legend-dataset-text {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

View File

@ -0,0 +1,116 @@
<template>
<div class="news-wrapper" v-if="news">
<div class="article" v-for="article in news" :key="article.id">
<a class="headline" :href="article.url">{{ article.title }}</a>
<div class="article-meta">
<span class="publisher">{{ article.author }}</span>
<span class="date">{{ article.published | date }}</span>
</div>
<p class="description">{{ article.description }}</p>
<img class="thumbnail" v-if="article.image && !hideImages" :src="article.image" alt="Image" />
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToDate } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
news: null,
};
},
computed: {
apiKey() {
if (!this.options.apiKey) this.error('An API key is required, see docs for more info');
return this.options.apiKey;
},
country() {
return this.options.country ? `&country=${this.options.country}` : '';
},
category() {
return this.options.category ? `&category=${this.options.category}` : '';
},
lang() {
return this.options.lang ? `&language=${this.options.lang}` : '';
},
count() {
return this.options.count ? `&page_size=${this.options.count}` : '';
},
keywords() {
return this.options.keywords ? `&keywords=${this.options.keywords}` : '';
},
hideImages() {
return this.options.hideImages;
},
endpoint() {
return `${widgetApiEndpoints.news}?apiKey=${this.apiKey}`
+ `${this.country}${this.category}${this.lang}${this.count}`;
},
},
filters: {
date(date) {
return timestampToDate(date);
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (!response.data.news || response.data.news.length === 0) {
this.error('API didn\'t return any results for your query');
}
this.news = response.data.news;
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
},
};
</script>
<style scoped lang="scss">
.news-wrapper {
.article {
padding-bottom: 1rem;
a.headline {
color: var(--widget-text-color);
display: inline-block;
font-weight: bold;
font-size: 1.2rem;
padding: 0.5rem 0;
text-decoration: none;
&:hover { text-decoration: underline; }
}
p.description {
color: var(--widget-text-color);
}
img.thumbnail {
width: 100%;
max-width: 24rem;
display: flex;
margin: 0 auto;
border-radius: var(--curve-factor);
}
.article-meta {
display: flex;
justify-content: space-between;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
font-size: 0.8rem;
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
}
</style>

View File

@ -0,0 +1,158 @@
<template>
<div class="pi-hole-stats-wrapper">
<!-- Current Status -->
<div v-if="status" class="status">
<span class="status-lbl">{{ $t('widgets.pi-hole.status-heading') }}:</span>
<span :class="`status-val ${getStatusColor(status)}`">{{ status | capitalize }}</span>
</div>
<!-- Block Pie Chart -->
<p :id="chartId" class="block-pie"></p>
<!-- More Data -->
<div v-if="dataTable" class="data-table">
<div class="data-table-row" v-for="(row, inx) in dataTable" :key="inx" >
<p class="row-label">{{ row.lbl }}</p>
<p class="row-value">{{ row.val }}</p>
</div>
</div>
</div>
</template>
<script>
// import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { capitalize } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
status: null,
dataTable: null,
blockPercentChart: null,
};
},
computed: {
/* Let user select which comic to display: random, latest or a specific number */
hostname() {
const usersChoice = this.options.hostname;
if (!usersChoice) this.error('You must specify the hostname for your Pi-Hole server');
return usersChoice || 'http://pi.hole';
},
endpoint() {
return `${this.hostname}/admin/api.php`;
},
hideStatus() { return this.options.hideStatus; },
hideChart() { return this.options.hideChart; },
hideInfo() { return this.options.hideInfo; },
},
filters: {
capitalize(str) {
return capitalize(str);
},
},
methods: {
/* Make GET request to local pi-hole instance */
fetchData() {
this.makeRequest(this.endpoint)
.then((response) => {
this.processData(response);
});
},
/* Assign data variables to the returned data */
processData(data) {
if (!this.hideStatus) {
this.status = data.status;
}
if (!this.hideInfo) {
this.dataTable = [
{ lbl: 'Active Clients', val: `${data.unique_clients}/${data.clients_ever_seen}` },
{ lbl: 'Ads Blocked Today', val: data.ads_blocked_today },
{ lbl: 'DNS Queries Today', val: data.dns_queries_today },
{ lbl: 'Total DNS Queries', val: data.dns_queries_all_types },
{ lbl: 'Domains on Block List', val: data.domains_being_blocked },
];
}
if (!this.hideChart) {
const blockedToday = Math.round(data.ads_percentage_today);
this.generateBlockPie(blockedToday);
}
},
getStatusColor(status) {
if (status === 'enabled') return 'green';
if (status === 'disabled') return 'red';
else return 'blue';
},
/* Generate pie chart showing the proportion of queries blocked */
generateBlockPie(blockedToday) {
const chartData = {
labels: ['Blocked', 'Allowed'],
datasets: [{
values: [blockedToday, 100 - blockedToday],
}],
};
return new this.Chart(`#${this.chartId}`, {
title: 'Block Percent',
data: chartData,
type: 'donut',
height: 250,
strokeWidth: 18,
colors: ['#f80363', '#20e253'],
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}%`,
},
});
},
},
};
</script>
<style scoped lang="scss">
.pi-hole-stats-wrapper {
display: flex;
flex-direction: column;
.status {
margin: 0.5rem 0;
.status-lbl {
color: var(--widget-text-color);
font-weight: bold;
}
.status-val {
margin-left: 0.5rem;
font-family: var(--font-monospace);
&.green { color: var(--success); }
&.red { color: var(--danger); }
&.blue { color: var(--info); }
}
}
img.block-percent-chart {
margin: 0.5rem auto;
max-width: 8rem;
width: 100%;
}
.block-pie {
margin: 0;
}
.data-table {
display: flex;
flex-direction: column;
.data-table-row {
display: flex;
justify-content: space-between;
p {
margin: 0.2rem 0;
color: var(--widget-text-color);
font-size: 0.9rem;
&.row-value {
font-family: var(--font-monospace);
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
}
</style>

View File

@ -0,0 +1,107 @@
<template>
<div class="pi-hole-queries-wrapper" v-if="results">
<div v-for="section in results" :key="section.id" class="query-section">
<p class="section-title">{{ section.title }}</p>
<div v-for="(query, i) in section.results" :key="i" class="query-row">
<p class="domain">{{ query.domain }}</p>
<p class="count">{{ query.count }}</p>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { showNumAsThousand } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
results: null,
};
},
computed: {
/* Let user select which comic to display: random, latest or a specific number */
hostname() {
const usersChoice = this.options.hostname;
if (!usersChoice) this.error('You must specify the hostname for your Pi-Hole server');
return usersChoice || 'http://pi.hole';
},
apiKey() {
if (!this.options.apiKey) this.error('API Key is required, please see the docs');
return this.options.apiKey;
},
count() {
const usersChoice = this.options.count;
if (usersChoice && typeof usersChoice === 'number') return usersChoice;
return 10;
},
endpoint() {
return `${this.hostname}/admin/api.php?topItems=${this.count}&auth=${this.apiKey}`;
},
},
methods: {
/* Make GET request to local pi-hole instance */
fetchData() {
this.makeRequest(this.endpoint)
.then((response) => {
if (Array.isArray(response)) {
this.error('Got success, but found no results, possible authorization error');
} else {
this.processData(response);
}
});
},
/* Assign data variables to the returned data */
processData(data) {
const topAds = [];
Object.keys(data.top_ads).forEach((domain) => {
topAds.push({ domain, count: showNumAsThousand(data.top_ads[domain]) });
});
const topQueries = [];
Object.keys(data.top_queries).forEach((domain) => {
topQueries.push({ domain, count: showNumAsThousand(data.top_queries[domain]) });
});
this.results = [
{ id: '01', title: 'Top Ads Blocked', results: topAds },
{ id: '02', title: 'Top Queries', results: topQueries },
];
},
},
};
</script>
<style scoped lang="scss">
.pi-hole-queries-wrapper {
color: var(--widget-text-color);
.query-section {
display: inline-block;
width: 100%;
p.section-title {
margin: 0.75rem 0 0.25rem;
font-size: 1.2rem;
font-weight: bold;
}
.query-row {
display: flex;
justify-content: space-between;
margin: 0.25rem;
p.domain {
margin: 0.25rem 0;
overflow: hidden;
text-overflow: ellipsis;
}
p.count {
margin: 0.25rem 0;
font-family: var(--font-monospace);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<div :id="chartId" class="pi-hole-traffic"></div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
status: null,
dataTable: null,
blockPercentChart: null,
};
},
computed: {
/* Let user select which comic to display: random, latest or a specific number */
hostname() {
const usersChoice = this.options.hostname;
if (!usersChoice) this.error('You must specify the hostname for your Pi-Hole server');
return usersChoice || 'http://pi.hole';
},
endpoint() {
return `${this.hostname}/admin/api.php?overTimeData10mins`;
},
},
methods: {
/* Make GET request to local pi-hole instance */
fetchData() {
this.makeRequest(this.endpoint)
.then((response) => {
if (this.validate(response)) {
this.processData(response);
}
});
},
validate(response) {
if (!response.ads_over_time || !response.domains_over_time) {
this.error('Expected data was not returned from Pi-Hole');
return false;
} else if (response.ads_over_time.length < 1) {
this.error('Request completed succesfully, but no data in Pi-Hole yet');
return false;
}
return true;
},
/* Assign data variables to the returned data */
processData(data) {
const timeData = [];
const domainsData = [];
Object.keys(data.domains_over_time).forEach((time) => {
timeData.push(this.formatTime(time * 1000));
domainsData.push(data.domains_over_time[time]);
});
const adsData = [];
Object.keys(data.ads_over_time).forEach((time) => {
adsData.push(data.ads_over_time[time]);
});
const chartData = {
labels: timeData,
datasets: [
{ name: 'Queries', type: 'bar', values: domainsData },
{ name: 'Ads Blocked', type: 'bar', values: adsData },
],
};
this.generateChart(chartData);
},
generateChart(chartData) {
return new this.Chart(`#${this.chartId}`, {
title: 'Recent Queries & Ads',
data: chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: ['#20e253', '#f80363'],
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
});
},
},
};
</script>

View File

@ -0,0 +1,121 @@
<template>
<div class="public-holidays-wrapper">
<div
v-for="(holiday, indx) in holidays"
:key="indx"
v-tooltip="tooltip(holiday)"
class="holiday-row"
>
<p class="holiday-date">{{ holiday.date }}</p>
<p class="holiday-name">{{ holiday.name }}</p>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToDate, capitalize } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
holidays: [],
};
},
computed: {
country() {
if (this.options.country) return this.options.country;
return navigator.language.split('-')[1] || 'GB';
},
holidayType() {
const options = ['all', 'public_holiday', 'observance',
'school_holiday', 'other_day', 'extra_working_day'];
const usersChoice = this.options.holidayType;
if (usersChoice && options.includes(usersChoice)) return usersChoice;
return 'public_holiday';
},
monthsToShow() {
const usersChoice = this.options.monthsToShow;
if (usersChoice && usersChoice > 0 && usersChoice <= 24) {
return usersChoice;
}
return 12;
},
startDate() {
const now = new Date();
return `${now.getDate()}-${now.getMonth() + 1}-${now.getFullYear()}`;
},
endDate() {
const now = new Date();
const then = new Date((now.setMonth(now.getMonth() + this.monthsToShow)));
return `${then.getDate()}-${then.getMonth() + 1}-${then.getFullYear()}`;
},
endpoint() {
return `${widgetApiEndpoints.holidays}`
+ `&fromDate=${this.startDate}&toDate=${this.endDate}`
+ `&country=${this.country}&holidayType=${this.holidayType}`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch holiday data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(holidays) {
const results = [];
const makeDate = (date) => timestampToDate(
new Date(`${date.year}-${date.month}-${date.day}`).getTime(),
);
const formatType = (ht) => capitalize(ht.replaceAll('_', ' '));
holidays.forEach((holiday) => {
results.push({
name: holiday.name[0].text,
date: makeDate(holiday.date),
type: formatType(holiday.holidayType),
observed: holiday.observedOn ? makeDate(holiday.observedOn) : '',
});
});
this.holidays = results;
},
tooltip(holiday) {
const observed = holiday.observed ? `<br><b>Observed On</b>: ${holiday.observed}` : '';
const content = `<b>Type</b>: ${holiday.type}${observed}`;
return {
content, trigger: 'hover focus', html: true, delay: 250, classes: 'in-modal-tt',
};
},
},
};
</script>
<style scoped lang="scss">
.public-holidays-wrapper {
padding: 0.5rem 0;
.holiday-row {
display: flex;
justify-content: space-between;
p {
margin: 0.25rem 0;
color: var(--widget-text-color);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<div class="ip-info-wrapper">
<p class="ip-address">{{ ipAddr }}</p>
<div class="region-wrapper" title="Open in Maps">
<img class="flag-image" :src="flagImg" alt="Flag" />
<div class="info-text">
<p class="isp-name">{{ ispName }}</p>
<a class="ip-location" :href="mapsUrl">{{ location }}</a>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { getCountryFlag, getMapUrl } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
ipAddr: null,
location: null,
ispName: null,
flagImg: null,
mapsUrl: null,
};
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(widgetApiEndpoints.publicIp)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch IP info', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(ipInfo) {
this.ipAddr = ipInfo.query;
this.ispName = ipInfo.isp;
this.location = `${ipInfo.city}, ${ipInfo.regionName}`;
this.flagImg = getCountryFlag(ipInfo.countryCode);
this.mapsUrl = getMapUrl({ lat: ipInfo.lat, lon: ipInfo.lon });
},
},
};
</script>
<style scoped lang="scss">
.ip-info-wrapper {
cursor: default;
p.ip-address {
font-size: 1.6rem;
margin: 0.5rem auto;
color: var(--widget-text-color);
font-family: var(--font-monospace);
}
.region-wrapper {
display: flex;
align-items: center;
img.flag-image {
width: 2rem;
border-radius: var(--curve-factor-small);
margin: 0.25rem 0.5rem 0 0;
}
a.ip-location {
font-size: 1rem;
margin: 0;
text-decoration: none;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover {
text-decoration: underline;
}
}
p.isp-name {
font-size: 1rem;
margin: 0.25rem 0 0 0;
color: var(--widget-text-color);
}
}
}
</style>

View File

@ -0,0 +1,229 @@
<template>
<div class="rss-wrapper">
<!-- Feed Meta Info -->
<a class="meta-container" v-if="meta" :href="meta.link" :title="meta.description">
<img class="feed-icon" :src="meta.image" v-if="meta.image" alt="Feed Image" />
<div class="feed-text">
<p class="feed-title">{{ meta.title }}</p>
<p class="feed-author" v-if="meta.author">By {{ meta.author }}</p>
</div>
</a>
<!-- Feed Content -->
<div class="post-wrapper" v-if="posts">
<div class="post-row" v-for="(post, indx) in posts" :key="indx">
<a class="post-top" :href="post.link">
<img class="post-img" :src="post.image" v-if="post.image" alt="Post Image">
<div class="post-title-wrap">
<p class="post-title">{{ post.title }}</p>
<p class="post-date">
{{ post.date | formatDate }} {{ post.author | formatAuthor }}
</p>
</div>
</a>
<div class="post-body" v-html="post.description"></div>
<a class="continue-reading-btn" :href="post.link">
{{ $t('widgets.general.open-link') }}
</a>
</div>
</div>
<!-- End Feed Content -->
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
meta: null,
posts: null,
};
},
computed: {
/* The URL to users atom-format RSS feed */
rssUrl() {
if (!this.options.rssUrl) this.error('Missing feed URL');
return encodeURIComponent(this.options.rssUrl || '');
},
apiKey() {
return this.options.apiKey;
},
limit() {
const usersChoice = this.options.limit;
if (usersChoice) return usersChoice;
return 10;
},
orderBy() {
const usersChoice = this.options.orderBy;
const options = ['title', 'pubDate', 'author'];
if (usersChoice && options.includes(usersChoice)) return usersChoice;
return 'pubDate';
},
orderDirection() {
const usersChoice = this.options.orderBy;
if (usersChoice && (usersChoice === 'desc' || usersChoice === 'asc')) return usersChoice;
return 'desc';
},
endpoint() {
const apiKey = this.apiKey ? `&api_key=${this.apiKey}` : '';
const limit = this.limit && this.apiKey ? `&count=${this.limit}` : '';
const orderBy = this.orderBy && this.apiKey ? `&order_by=${this.orderBy}` : '';
const direction = this.orderDirection ? `&order_dir=${this.orderDirection}` : '';
return `${widgetApiEndpoints.rssToJson}?rss_url=${this.rssUrl}`
+ `${apiKey}${limit}${orderBy}${direction}`;
},
},
filters: {
formatDate(timestamp) {
const localFormat = navigator.language;
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
return new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
},
formatAuthor(author) {
return author ? `by ${author}` : '';
},
},
methods: {
/* Make GET request to Rss2Json */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((error) => {
this.error('Unable to RSS feed', error);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const { feed, items } = data;
this.meta = {
title: feed.title,
link: feed.link,
author: feed.author,
description: feed.description,
image: feed.image,
};
const posts = [];
items.forEach((post) => {
posts.push({
title: post.title,
description: post.description,
image: post.thumbnail,
author: post.author,
date: post.pubDate,
link: post.link,
});
});
this.posts = posts;
},
},
};
</script>
<style scoped lang="scss">
.rss-wrapper {
.meta-container {
display: flex;
align-items: center;
text-decoration: none;
margin: 0.25rem 0 0.5rem 0;
p.feed-title {
margin: 0;
font-size: 1.2rem;
font-weight: bold;
color: var(--widget-text-color);
}
p.feed-author {
margin: 0;
font-size: 0.8rem;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
img.feed-icon {
border-radius: var(--curve-factor);
width: 2rem;
height: 2rem;
margin-right: 0.5rem;
}
}
.post-row {
border-top: 1px dashed var(--widget-text-color);
padding: 0.5rem 0 0.25rem 0;
.post-top {
display: flex;
align-items: center;
text-decoration: none;
p.post-title {
margin: 0;
font-size: 1rem;
font-weight: bold;
color: var(--widget-text-color);
}
p.post-date {
font-size: 0.8rem;
margin: 0;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
img.post-img {
border-radius: var(--curve-factor);
width: 2rem;
height: 2rem;
margin-right: 0.5rem;
}
}
.post-body {
font-size: 0.85rem;
color: var(--widget-text-color);
max-height: 400px;
overflow: hidden;
::v-deep p {
margin: 0.5rem 0;
}
::v-deep img {
max-width: 80%;
display: flex;
margin: 0 auto;
border-radius: var(--curve-factor);
}
::v-deep a {
color: var(--widget-text-color);
}
::v-deep svg path {
fill: var(--widget-text-color);
}
::v-deep blockquote {
margin-left: 0.5rem;
padding-left: 0.5rem;
border-left: 4px solid var(--widget-text-color);
}
::v-deep .avatar.avatar-user { display: none; }
}
a.continue-reading-btn {
width: 100%;
display: block;
font-size: 0.9rem;
text-align: right;
margin: 0 0 0.25rem;
padding: 0.1rem 0.25rem;
text-decoration: none;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
&:hover, &:focus {
opacity: 1;
text-decoration: underline;
}
}
}
}
</style>

View File

@ -0,0 +1,337 @@
<template>
<div class="sports-scores-wrapper" v-if="matches">
<!-- Show back to original button -->
<p v-if="whatToShow === 'team' && currentTeamId !== teamId"
@click="fetchTeamScores(teamId)" class="back-to-original">
Back to Original Team
</p>
<p v-else-if="whatToShow === 'league' && leagueId && currentLeagueId !== leagueId"
@click="fetchLeagueScores(leagueId)" class="back-to-original">
Back to Original League
</p>
<!-- Show toggle switch for past and future matches -->
<div class="past-or-future">
<span
:class="`btn ${whenToShow === 'past' ? 'selected' : ''}`"
v-tooltip="tooltip('View Recent Scores')"
@click="fetchPastFutureEvents('past')"
>
Past Scores
</span>
<span
:class="`btn ${whenToShow === 'future' ? 'selected' : ''}`"
v-tooltip="tooltip('View Upcoming Games')"
@click="fetchPastFutureEvents('future')"
>
Upcoming Games
</span>
</div>
<div class="match-row" v-for="match in matches" :key="match.id">
<!-- Banner Image -->
<div class="match-thumbnail-wrap">
<img :src="match.thumbnail" :alt="`${match.title} Banner Image`" class="match-thumbnail" />
</div>
<!-- Team Scores -->
<div class="score">
<div
:class="`score-block home ${currentTeamId !== match.home.id ? 'clickable' : ''}`"
v-tooltip="tooltip(`Click to view ${match.home.name} Scores`)"
@click="fetchTeamScores(match.home.id)"
>
<p class="team-score">{{ match.home.score }}</p>
<p class="team-name">{{ match.home.name }}</p>
<p class="team-location">Home</p>
</div>
<div class="colon">{{ match.home.score || match.away.score ? ':' : 'v' }}</div>
<div
class="score-block away clickable"
v-tooltip="tooltip(`Click to view ${match.away.name} Scores`)"
@click="fetchTeamScores(match.away.id)"
>
<p class="team-score">{{ match.away.score }}</p>
<p class="team-name">{{ match.away.name }}</p>
<p class="team-location">Away</p>
</div>
</div>
<!-- Match Meta Info -->
<div class="match-info">
<p class="status">{{ match.status }} </p>
<p class="league" @click="fetchLeagueScores(match.leagueId)">
{{ match.league }}, {{ match.season }}
</p>
<p>
<a :href="match.venue | mapsUrl">{{ match.venue }}</a>
on {{ match.date | formatDate }} ({{ match.time | formatTime }})</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { timestampToDate, getPlaceUrl } from '@/utils/MiscHelpers';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
data() {
return {
currentTeamId: null, // ID of the selected team
currentLeagueId: null, // ID of selected league
whenToShow: null, // Either 'past' or 'future'
whatToShow: null, // Either 'team' or 'league'
matches: null, // Array of matches returned
initiated: false, // Set to true once values set
};
},
computed: {
teamId() {
return this.options.teamId;
},
leagueId() {
return this.options.leagueId;
},
apiKey() {
return this.options.apiKey || '50130162';
},
limit() {
return this.options.limit || 20;
},
pastOrFuture() {
return this.options.pastOrFuture || 'past';
},
endpoint() {
this.initiate();
const endpoint = widgetApiEndpoints.sportsScores;
if (this.whatToShow === 'league' && this.whenToShow === 'future') {
return `${endpoint}/${this.apiKey}/eventsnextleague.php?id=${this.currentLeagueId}`;
} else if (this.whatToShow === 'league' && this.whenToShow === 'past') {
return `${endpoint}/${this.apiKey}/eventspastleague.php?id=${this.currentLeagueId}`;
} else if (this.whatToShow === 'team' && this.whenToShow === 'future') {
return `${endpoint}/${this.apiKey}/eventsnext.php?id=${this.currentTeamId}`;
} else if (this.whatToShow === 'team' && this.whenToShow === 'past') {
return `${endpoint}/${this.apiKey}/eventslast.php?id=${this.currentTeamId}`;
} else {
this.error('Missing team or league ID');
return '';
}
},
},
filters: {
formatDate(dateStr) {
return timestampToDate(dateStr);
},
formatTime(timeStr) {
if (!timeStr) return '';
return timeStr.slice(0, 5);
},
mapsUrl(placeName) {
return getPlaceUrl(placeName);
},
},
methods: {
initiate() {
if (!this.initiated) {
this.currentTeamId = this.teamId;
this.currentLeagueId = this.leagueId;
this.whenToShow = this.pastOrFuture;
this.whatToShow = this.teamOrLeague();
this.initiated = true;
}
},
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data.results || response.data.events);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
this.finishLoading();
})
.finally(() => {
this.finishLoading();
});
},
processData(data) {
const matches = [];
data.forEach((match) => {
matches.push({
id: match.idEvent,
sport: match.strSport,
title: match.strEvent,
league: match.strLeague,
leagueId: match.idLeague,
season: match.strSeason,
venue: match.strVenue,
date: match.dateEvent,
time: match.strTime,
status: match.strStatus,
thumbnail: match.strThumb,
home: {
id: match.idHomeTeam,
name: match.strHomeTeam,
score: match.intHomeScore,
},
away: {
id: match.idAwayTeam,
name: match.strAwayTeam,
score: match.intAwayScore,
},
});
});
this.matches = matches.slice(0, this.limit);
},
teamOrLeague() {
if (!this.currentTeamId && !this.currentLeagueId) {
this.error('You must specify either a teamId or leagueId');
}
if (this.currentTeamId) return 'team';
return 'league';
},
fetchTeamScores(teamId) {
if (teamId) {
this.whatToShow = 'team';
this.startLoading();
this.currentTeamId = teamId;
this.fetchData();
}
},
fetchLeagueScores(leagueId) {
if (leagueId) {
this.whatToShow = 'league';
this.startLoading();
this.currentLeagueId = leagueId;
this.fetchData();
}
},
fetchPastFutureEvents(pastOrFuture) {
this.startLoading();
this.whenToShow = pastOrFuture;
this.fetchData();
},
tooltip(content) {
return {
content, html: true, trigger: 'hover focus', delay: 250,
};
},
},
};
</script>
<style scoped lang="scss">
.sports-scores-wrapper {
p {
font-size: 1rem;
margin: 0.5rem auto;
color: var(--widget-text-color);
}
.match-row {
.match-thumbnail-wrap {
width: 80%;
max-height: 5rem;
display: flex;
border-radius: var(--curve-factor);
margin: 1rem auto 0.5rem auto;
overflow: hidden;
img.match-thumbnail {
width: 100%;
height: fit-content;
margin-top: -13%;
}
}
.score {
display: flex;
justify-content: space-around;
.score-block {
display: flex;
flex-direction: column;
min-width: 40%;
border: 1px solid transparent;
border-radius: var(--curve-factor);
p.team-score {
margin: 0.25rem auto;
font-size: 1.5rem;
font-weight: bold;
font-family: var(--font-monospace);
}
p.team-name {
text-align: center;
margin: 0;
}
p.team-location {
font-size: 0.8rem;
margin: 0 auto;
opacity: var(--dimming-factor);
}
&.clickable {
cursor: pointer;
&:hover {
border: 1px dashed var(--widget-text-color);
}
}
}
.colon {
margin: 0;
font-size: 2rem;
font-weight: bold;
color: var(--widget-text-color);
}
}
.match-info {
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
padding: 0.25rem 0.5rem;
margin: 0.5rem auto 1rem auto;
p, a {
color: var(--widget-text-color);
opacity: var(--dimming-factor);
font-size: 0.8rem;
margin: 0;
&.status {
font-weight: bold;
}
&.league {
text-decoration: underline;
cursor: pointer;
}
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
p.back-to-original {
cursor: pointer;
font-size: 1rem;
padding: 0.1rem 0.25rem;
width: 100%;
color: var(--widget-text-color);
border-radius: var(--curve-factor);
text-decoration: underline;
text-align: left;
}
.past-or-future {
width: 100%;
color: var(--widget-text-color);
border-bottom: 1px dashed var(--widget-text-color);
padding: 0.5rem 0;
display: flex;
justify-content: space-evenly;
span.btn {
max-width: 50%;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: var(--curve-factor);
&.selected {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
&:hover {
font-weight: bold;
}
}
}
}
</style>

View File

@ -0,0 +1,176 @@
<template>
<div class="stat-ping-wrapper">
<div
class="service-row"
v-for="(service, indx) in services"
:key="indx"
v-tooltip="makeTooltip(service)"
>
<!-- Title -->
<p class="service-name">
{{ service.name }}:
<span v-if="service.online" class="status-online">
{{ $t('widgets.stat-ping.up') }}
</span>
<span v-else class="status-offline">
{{ $t('widgets.stat-ping.down') }}
</span>
</p>
<!-- Charts -->
<div class="charts">
<img
class="uptime-pie-chart" alt="24 Hour Uptime Chart"
:src="makeChartUrl(service.uptime24, '24 Hours')" />
<img class="uptime-pie-chart" alt="7 Day Uptime Chart"
:src="makeChartUrl(service.uptime7, '7 Days')" />
</div>
<!-- Info -->
<div class="info">
<div class="info-row">
<span class="lbl">Failed Pings</span>
<span class="val">{{ service.totalFailure }}/{{ service.totalSuccess }}</span>
</div>
<div class="info-row">
<span class="lbl">Last Success</span><span class="val">{{ service.lastSuccess }}</span>
</div>
<div class="info-row">
<span class="lbl">Last Failure</span><span class="val">{{ service.lastFailure }}</span>
</div>
<div class="info-row">
<span class="lbl">Avg Response Time</span>
<span class="val">{{ service.responseTime }} ms</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { serviceEndpoints } from '@/utils/defaults';
import { showNumAsThousand, getTimeAgo } from '@/utils/MiscHelpers';
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
services: null,
};
},
computed: {
hostname() {
if (!this.options.hostname) this.error('A hostname is required');
return this.options.hostname;
},
limit() {
return this.options.limit;
},
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
endpoint() {
return `${this.hostname}/api/services`;
},
},
methods: {
fetchData() {
this.overrideProxyChoice = true;
this.makeRequest(this.endpoint).then(this.processData);
},
makeChartUrl(uptime, title) {
const host = 'https://quickchart.io';
const chartId = 'zm-d3d5134f-5920-49d1-92ab-303aaaf8cb0b';
return `${host}/chart/render/${chartId}?data1=${uptime},${100 - uptime}&title=${title}`;
},
makeTooltip(service) {
const {
responseTime, totalFailure, totalSuccess, lastSuccess, lastFailure,
} = service;
const content = `<b>Failed Pings:</b> ${totalFailure}/${totalSuccess}<br>`
+ `<b>Response Time:</b> ${responseTime}ms<br>`
+ `<b>Last Success:</b> ${lastSuccess}<br>`
+ `<b>Last Failure:</b> ${lastFailure}`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'ping-times-tt',
};
},
processData(data) {
let services = [];
data.forEach((service) => {
services.push({
name: service.name,
online: service.online,
uptime7: service.online_7_days,
uptime24: service.online_24_hours,
responseTime: Math.round(service.avg_response / 1000),
totalSuccess: showNumAsThousand(service.stats.hits),
totalFailure: showNumAsThousand(service.stats.failures),
lastSuccess: getTimeAgo(service.last_success),
lastFailure: getTimeAgo(service.last_error),
});
});
if (this.limit) services = services.slice(0, this.limit);
this.services = services;
},
},
};
</script>
<style scoped lang="scss">
.stat-ping-wrapper {
p {
color: var(--widget-text-color);
margin: 0.5rem 0;
}
.service-row {
p.service-name {
font-size: 1.2rem;
font-weight: bold;
span {
margin-left: 0.25rem;
font-family: var(--font-monospace);
&.status-online { color: var(--success); }
&.status-offline { color: var(--danger); }
}
}
.charts {
display: flex;
flex-direction: row;
justify-content: space-evenly;
img.uptime-pie-chart {
width: 35%;
margin: 0.5rem;
}
}
.info {
opacity: var(--dimming-factor);
margin: 1rem auto;
width: fit-content;
background: var(--background);
padding: 0.5rem;
border-radius: var(--curve-factor);
.info-row {
display: flex;
span {
color: var(--widget-text-color);
font-size: 0.8rem;
&.lbl {
font-weight: bold;
margin-right: 0.25rem;
min-width: 8rem;
}
&.val {
font-family: var(--font-monospace);
}
}
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>

View File

@ -0,0 +1,169 @@
<template>
<div class="crypto-price-chart" :id="chartId"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm';
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
chartData: null,
chartDom: null,
};
},
computed: {
/* The stock or share asset symbol to fetch data for */
stock() {
return this.options.stock;
},
/* The time interval between data points, in minutes */
interval() {
return `${(this.options.interval || 30)}min`;
},
/* The users API key for AlphaVantage */
apiKey() {
return this.options.apiKey;
},
/* The formatted GET request API endpoint to fetch stock data from */
endpoint() {
const func = 'TIME_SERIES_INTRADAY';
return `${widgetApiEndpoints.stockPriceChart}?function=${func}`
+ `&symbol=${this.stock}&interval=${this.interval}&apikey=${this.apiKey}`;
},
/* The number of data points to render on the chart */
dataPoints() {
const userChoice = this.options.dataPoints;
if (typeof usersChoice === 'number' && userChoice < 100 && userChoice > 5) {
return userChoice;
}
return 30;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `stock-price-chart-${Math.round(Math.random() * 10000)}`;
},
/* Which price for each interval should be used (API requires in stupid format) */
priceTime() {
const usersChoice = this.options.priceTime || 'high';
switch (usersChoice) {
case ('open'): return '1. open';
case ('high'): return '2. high';
case ('low'): return '3. low';
case ('close'): return '4. close';
case ('volume'): return '5. volume';
default: return '2. high';
}
},
},
methods: {
/* Create new chart, using the crypto data */
generateChart() {
return new Chart(`#${this.chartId}`, {
title: `${this.stock} Price Chart`,
data: this.chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `$${d}`,
},
});
},
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (response.data.note) {
this.error('API Error', response.data.Note);
} else if (response.data['Error Message']) {
this.error('API Error', response.data['Error Message']);
} else {
this.processData(response.data);
}
})
.catch((error) => {
this.error('Unable to fetch stock price data', error);
})
.finally(() => {
this.finishLoading();
});
},
/* Convert data returned by API into a format that can be consumed by the chart
* To improve efficiency, only a certain amount of data points are plotted
*/
processData(data) {
const priceLabels = [];
const priceValues = [];
const dataKey = `Time Series (${this.interval})`;
const rawMarketData = data[dataKey];
const interval = Math.round(Object.keys(rawMarketData).length / this.dataPoints);
Object.keys(rawMarketData).forEach((timeGroup, index) => {
if (index % interval === 0) {
priceLabels.push(this.formatDate(timeGroup));
priceValues.push(this.formatPrice(rawMarketData[timeGroup][this.priceTime]));
}
});
// // Combine results with chart config
this.chartData = {
labels: priceLabels.reverse(),
datasets: [
{ name: `Price ${this.priceTime}`, type: 'bar', values: priceValues.reverse() },
],
};
// // Call chart render function
this.renderChart();
},
/* Uses class data to render the line chart */
renderChart() {
this.chartDom = this.generateChart();
},
/* Format the date for a given time stamp, also include time if required */
formatDate(timestamp) {
const localFormat = navigator.language;
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
return new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
},
/* Format the price, rounding to given number of decimal places */
formatPrice(priceStr) {
const price = parseFloat(priceStr);
let numDecimals = 0;
if (price < 10) numDecimals = 1;
if (price < 1) numDecimals = 2;
if (price < 0.1) numDecimals = 3;
if (price < 0.01) numDecimals = 4;
if (price < 0.001) numDecimals = 5;
return price.toFixed(numDecimals);
},
},
};
</script>
<style lang="scss">
.crypto-price-chart .chart-container {
text.title {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More