forked from platypush/platypush
New Vue.js template for dashbord WIP
This commit is contained in:
parent
0c0e7411f7
commit
39abdfe40a
32 changed files with 13983 additions and 20 deletions
|
@ -10,8 +10,8 @@ from platypush.backend.http.app.utils import get_routes
|
|||
base_folder = os.path.abspath(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), '..'))
|
||||
|
||||
template_folder = os.path.join(base_folder, 'templates')
|
||||
static_folder = os.path.join(base_folder, 'static')
|
||||
template_folder = os.path.join(base_folder, 'dist')
|
||||
static_folder = os.path.join(base_folder, 'dist/static')
|
||||
|
||||
application = Flask('platypush', template_folder=template_folder,
|
||||
static_folder=static_folder)
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
from flask import Blueprint, request, render_template
|
||||
|
||||
from platypush.backend.http.app import template_folder, static_folder
|
||||
from platypush.backend.http.app import template_folder
|
||||
from platypush.backend.http.app.utils import authenticate, get_websocket_port
|
||||
|
||||
from platypush.backend.http.utils import HttpUtils
|
||||
from platypush.config import Config
|
||||
|
||||
dashboard = Blueprint('dashboard', __name__, template_folder=template_folder)
|
||||
|
||||
|
@ -14,18 +12,13 @@ __routes__ = [
|
|||
]
|
||||
|
||||
|
||||
@dashboard.route('/dashboard', methods=['GET'])
|
||||
@dashboard.route('/dashboard/<name>', methods=['GET'])
|
||||
@authenticate()
|
||||
def dashboard():
|
||||
""" Route for the fullscreen dashboard """
|
||||
http_conf = Config.get('backend.http')
|
||||
dashboard_conf = http_conf.get('dashboard', {})
|
||||
|
||||
return render_template('dashboard.html', config=dashboard_conf,
|
||||
utils=HttpUtils, token=Config.get('token'),
|
||||
static_folder=static_folder, template_folder=template_folder,
|
||||
websocket_port=get_websocket_port(),
|
||||
has_ssl=http_conf.get('ssl_cert') is not None)
|
||||
def render_dashboard(*_, **__):
|
||||
""" Route for the dashboard """
|
||||
return render_template('index.html',
|
||||
utils=HttpUtils,
|
||||
websocket_port=get_websocket_port())
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -7,7 +7,7 @@ from platypush.config import Config
|
|||
from platypush.backend.http.app import template_folder, static_folder
|
||||
|
||||
|
||||
img_folder = os.path.join(static_folder, 'resources', 'img')
|
||||
img_folder = os.path.join(template_folder, 'img')
|
||||
resources = Blueprint('resources', __name__, template_folder=template_folder)
|
||||
favicon = Blueprint('favicon', __name__, template_folder=template_folder)
|
||||
img = Blueprint('img', __name__, template_folder=template_folder)
|
||||
|
@ -57,9 +57,9 @@ def resources_path(path):
|
|||
|
||||
|
||||
@favicon.route('/favicon.ico', methods=['GET'])
|
||||
def favicon():
|
||||
def serve_favicon():
|
||||
""" favicon.ico icon """
|
||||
return send_from_directory(img_folder, 'favicon.ico')
|
||||
return send_from_directory(template_folder, 'favicon.ico')
|
||||
|
||||
@img.route('/img/<path:path>', methods=['GET'])
|
||||
def imgpath(path):
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
Place in this folder the static resources you want to serve over HTTP (images, fonts etc.)
|
23
platypush/backend/http/webapp/.gitignore
vendored
Normal file
23
platypush/backend/http/webapp/.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
24
platypush/backend/http/webapp/README.md
Normal file
24
platypush/backend/http/webapp/README.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
# webapp
|
||||
|
||||
## Project setup
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
5
platypush/backend/http/webapp/babel.config.js
Normal file
5
platypush/backend/http/webapp/babel.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
12758
platypush/backend/http/webapp/package-lock.json
generated
Normal file
12758
platypush/backend/http/webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
50
platypush/backend/http/webapp/package.json
Normal file
50
platypush/backend/http/webapp/package.json
Normal file
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "platypush",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
"axios": "^0.21.0",
|
||||
"bulma": "^0.9.1",
|
||||
"core-js": "^3.6.5",
|
||||
"mitt": "^2.1.0",
|
||||
"node-sass": "^5.0.0",
|
||||
"sass-loader": "^10.1.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-router": "^4.0.0-rc.3",
|
||||
"vue-skycons": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/compiler-sfc": "^3.0.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-vue": "^7.0.0-0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"rules": {}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
]
|
||||
}
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
23
platypush/backend/http/webapp/public/index.html
Normal file
23
platypush/backend/http/webapp/public/index.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<script type="text/javascript">
|
||||
window.config = JSON.parse(`{
|
||||
"websocketPort": "{{ websocket_port }}"
|
||||
}`)
|
||||
</script>
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
67
platypush/backend/http/webapp/src/App.vue
Normal file
67
platypush/backend/http/webapp/src/App.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<router-view />
|
||||
<Notifications ref="notifications" />
|
||||
<Events ref="events" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Notifications from "@/components/Notifications";
|
||||
import Utils from "@/Utils";
|
||||
import { bus } from '@/bus';
|
||||
import Events from "@/Events";
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {Events, Notifications},
|
||||
mixins: [Utils],
|
||||
|
||||
computed: {
|
||||
config() {
|
||||
const cfg = {
|
||||
websocketPort: 8009,
|
||||
}
|
||||
|
||||
if (this._config.websocketPort && !this._config.websocketPort.startsWith('{{')) {
|
||||
cfg.websocketPort = parseInt(this._config.websocketPort)
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onNotification(notification) {
|
||||
this.$refs.notifications.create(notification)
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
bus.on('notification-create', this.onNotification)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style lang="scss">
|
||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
||||
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
|
||||
@import "~@fortawesome/fontawesome-free/scss/solid"; // fas
|
||||
@import "~@fortawesome/fontawesome-free/scss/regular"; // far
|
||||
@import "~@fortawesome/fontawesome-free/scss/brands"; // fab
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
120
platypush/backend/http/webapp/src/Events.vue
Normal file
120
platypush/backend/http/webapp/src/Events.vue
Normal file
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<div id="__platypush_events"/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Events",
|
||||
data() {
|
||||
return {
|
||||
ws: null,
|
||||
pending: false,
|
||||
opened: false,
|
||||
timeout: null,
|
||||
reconnectMsecs: 30000,
|
||||
handlers: [],
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
init() {
|
||||
try {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws'
|
||||
const url = `${protocol}://${location.hostname}:${this.$root.config.websocketPort}`
|
||||
this.ws = new WebSocket(url)
|
||||
} catch (err) {
|
||||
console.error('Websocket initialization error')
|
||||
console.log(err)
|
||||
return
|
||||
}
|
||||
|
||||
this.pending = true
|
||||
|
||||
const onWebsocketTimeout = function(self) {
|
||||
return function() {
|
||||
console.log('Websocket reconnection timed out, retrying')
|
||||
this.pending = false
|
||||
self.close()
|
||||
self.onclose()
|
||||
}
|
||||
}
|
||||
|
||||
this.timeout = setTimeout(
|
||||
onWebsocketTimeout(this.ws), this.reconnectMsecs)
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const handlers = []
|
||||
event = event.data
|
||||
|
||||
if (typeof event === 'string') {
|
||||
try {
|
||||
event = JSON.parse(event)
|
||||
} catch (e) {
|
||||
console.warn('Received invalid non-JSON event')
|
||||
console.warn(event)
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(event)
|
||||
if (event.type !== 'event') {
|
||||
// Discard non-event messages
|
||||
return
|
||||
}
|
||||
|
||||
if (null in this.handlers) {
|
||||
handlers.push(this.handlers[null])
|
||||
}
|
||||
|
||||
if (event.args.type in this.handlers) {
|
||||
handlers.push(...this.handlers[event.args.type])
|
||||
}
|
||||
|
||||
for (const handler of handlers) {
|
||||
handler(event.args)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
if (this.opened) {
|
||||
console.log("There's already an opened websocket connection, closing the newly opened one")
|
||||
this.onclose = () => {}
|
||||
this.close()
|
||||
}
|
||||
|
||||
console.log('Websocket connection successful')
|
||||
this.opened = true
|
||||
|
||||
if (this.pending) {
|
||||
this.pending = false
|
||||
}
|
||||
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout)
|
||||
this.timeout = undefined
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
console.error(event)
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
if (event) {
|
||||
console.log('Websocket closed - code: ' + event.code + ' - reason: ' + event.reason)
|
||||
}
|
||||
|
||||
this.opened = false
|
||||
|
||||
if (!this.pending) {
|
||||
this.pending = true
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
}
|
||||
</script>
|
9
platypush/backend/http/webapp/src/Utils.vue
Normal file
9
platypush/backend/http/webapp/src/Utils.vue
Normal file
|
@ -0,0 +1,9 @@
|
|||
<script>
|
||||
import Api from "@/utils/Api";
|
||||
import Notification from "@/utils/Notification";
|
||||
|
||||
export default {
|
||||
name: "Utils",
|
||||
mixins: [Api, Notification],
|
||||
}
|
||||
</script>
|
5
platypush/backend/http/webapp/src/bus.js
Normal file
5
platypush/backend/http/webapp/src/bus.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import mitt from 'mitt';
|
||||
|
||||
const bus = mitt();
|
||||
|
||||
export { bus };
|
87
platypush/backend/http/webapp/src/components/Loading.vue
Normal file
87
platypush/backend/http/webapp/src/components/Loading.vue
Normal file
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<div class="loading">
|
||||
<div class="icon">
|
||||
<div v-for="n in 4" :key="n" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3em;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $loading-bg;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
top: 33px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
|
||||
&:nth-child(1) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
left: 32px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
left: 56px;
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(24px, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
106
platypush/backend/http/webapp/src/components/Notification.vue
Normal file
106
platypush/backend/http/webapp/src/components/Notification.vue
Normal file
|
@ -0,0 +1,106 @@
|
|||
<template>
|
||||
<div class="notification fade-in" :class="{warning: warning, error: error}" @click="clicked">
|
||||
<div class="title" v-if="title" v-text="title"></div>
|
||||
<div class="body">
|
||||
<div class="image col-3" v-if="image || warning || error">
|
||||
<div class="row">
|
||||
<img :src="image.src" v-if="image && image.src" alt="">
|
||||
<i :class="['fa', 'fa-' + image.icon]" :style="image.color ? '--color: ' + image.color : ''"
|
||||
v-else-if="image && image.icon"></i>
|
||||
<i :class="image.iconClass" :style="image.color ? '--color: ' + image.color : ''"
|
||||
v-else-if="image && image.iconClass"></i>
|
||||
<i class="fa fa-exclamation" v-else-if="warning"></i>
|
||||
<i class="fa fa-times" v-else-if="error"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text col-9" v-if="text && !!image" v-text="text"></div>
|
||||
<div class="text col-9" v-if="html && !!image" v-html="html"></div>
|
||||
<div class="text row horizontal-center" v-if="text && !image" v-text="text"></div>
|
||||
<div class="text row horizontal-center" v-if="html && !image" v-html="html"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Notification",
|
||||
props: ['id','text','html','title','image','link','error','warning'],
|
||||
|
||||
methods: {
|
||||
clicked() {
|
||||
if (this.link) {
|
||||
window.open(this.link, '_blank');
|
||||
}
|
||||
|
||||
this.$emit('clicked', this.id);
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notification {
|
||||
background: $notification-bg;
|
||||
border: $notification-border;
|
||||
border-radius: .5rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-right: 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $notification-hover-bg;
|
||||
&.warning { background: $notification-warning-hover-bg; }
|
||||
&.error { background: $notification-error-hover-bg; }
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: $notification-warning-bg;
|
||||
border: $notification-warning-border;
|
||||
.image { --color: $notification-warning-icon-color; }
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: $notification-error-bg;
|
||||
border: $notification-error-border;
|
||||
.image { --color: $notification-error-icon-color; }
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: .4rem;
|
||||
line-height: 3rem;
|
||||
letter-spacing: .1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.body {
|
||||
@extend .vertical-center;
|
||||
height: 6em;
|
||||
overflow: hidden;
|
||||
padding-bottom: 1rem;
|
||||
letter-spacing: .05rem;
|
||||
}
|
||||
|
||||
.image {
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
--color: $notification-icon-color;
|
||||
|
||||
.row {
|
||||
@extend .vertical-center;
|
||||
@extend .horizontal-center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.fa {
|
||||
font-size: 2.5rem;
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<div class="notifications">
|
||||
<Notification v-for="(notification, id, index) in notifications"
|
||||
:key="index"
|
||||
:id="id"
|
||||
:text="notification.text"
|
||||
:html="notification.html"
|
||||
:title="notification.title"
|
||||
:link="notification.link"
|
||||
:image="notification.image"
|
||||
:warning="notification.warning"
|
||||
:error="notification.error"
|
||||
@clicked="destroy">
|
||||
</Notification>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Notification from "@/components/Notification";
|
||||
|
||||
export default {
|
||||
name: "Notifications",
|
||||
components: {Notification},
|
||||
props: {
|
||||
duration: {
|
||||
// Default notification duration in milliseconds
|
||||
type: Number,
|
||||
default: 10000,
|
||||
}
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
index: 0,
|
||||
notifications: {},
|
||||
timeouts: {},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
create: function(args) {
|
||||
const id = this.index++;
|
||||
this.notifications[id] = args;
|
||||
|
||||
if (args.duration == null) {
|
||||
args.duration = this.duration;
|
||||
}
|
||||
|
||||
const duration = args.duration ? parseInt(args.duration) : 0;
|
||||
if (duration) {
|
||||
this.timeouts[id] = setTimeout(this.destroy.bind(null, id), duration);
|
||||
}
|
||||
},
|
||||
|
||||
destroy: function(id) {
|
||||
delete this.notifications[id];
|
||||
delete this.timeouts[id];
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notifications {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 25em;
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
7
platypush/backend/http/webapp/src/main.js
Normal file
7
platypush/backend/http/webapp/src/main.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import router from '@/router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.config.globalProperties._config = window.config
|
||||
app.use(router).mount('#app')
|
22
platypush/backend/http/webapp/src/router/index.js
Normal file
22
platypush/backend/http/webapp/src/router/index.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { createWebHistory, createRouter } from "vue-router";
|
||||
import Dashboard from "@/views/Dashboard.vue";
|
||||
import NotFound from "@/views/NotFound";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: "/dashboard/:name",
|
||||
name: "Dashboard",
|
||||
component: Dashboard,
|
||||
},
|
||||
{
|
||||
path: "/:catchAll(.*)",
|
||||
component: NotFound,
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
95
platypush/backend/http/webapp/src/style/layout.scss
Normal file
95
platypush/backend/http/webapp/src/style/layout.scss
Normal file
|
@ -0,0 +1,95 @@
|
|||
$widths: (
|
||||
s: '(max-width: 720px)',
|
||||
m: '(max-width: 1024px) and (min-width: 720px)',
|
||||
l: '(min-width: 1024px)',
|
||||
);
|
||||
|
||||
@for $i from 1 through 12 {
|
||||
.col-#{$i} {
|
||||
float: left;
|
||||
box-sizing: border-box;
|
||||
|
||||
@if $i < 12 {
|
||||
width: (4.66666666667%*$i) + (4% * if($i > 1, $i - 1, 0));
|
||||
margin-left: 4%;
|
||||
} @else {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.col-no-margin-#{$i} {
|
||||
float: left;
|
||||
box-sizing: border-box;
|
||||
width: ((100%/12)*$i);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@if $i < 12 {
|
||||
.col-offset-#{$i}:first-child {
|
||||
margin-left: (8.66666666667%*$i) !important;
|
||||
}
|
||||
.col-offset-#{$i}:not(first-child) {
|
||||
margin-left: 4% + (8.66666666667%*$i) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@each $size, $width in $widths {
|
||||
@media #{$width} {
|
||||
@for $i from 1 through 12 {
|
||||
.col-#{$size}-#{$i} {
|
||||
float: left;
|
||||
box-sizing: border-box;
|
||||
|
||||
@if $i < 12 {
|
||||
width: (4.66666666667%*$i) + (4% * if($i > 1, $i - 1, 0));
|
||||
margin-left: 4%;
|
||||
} @else {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@if $i < 12 {
|
||||
.col-offset-#{$size}-#{$i} {
|
||||
margin-left: (8.66666666667%*$i);
|
||||
}
|
||||
}
|
||||
|
||||
.col-no-margin-#{$size}-#{$i} {
|
||||
float: left;
|
||||
box-sizing: border-box;
|
||||
width: ((100%/12)*$i);
|
||||
}
|
||||
}
|
||||
|
||||
.#{$size}-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.#{$size}-visible {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.horizontal-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
23
platypush/backend/http/webapp/src/style/themes/light.scss
Normal file
23
platypush/backend/http/webapp/src/style/themes/light.scss
Normal file
|
@ -0,0 +1,23 @@
|
|||
//// General
|
||||
$background-color: white;
|
||||
|
||||
//// Notifications
|
||||
$notification-bg: rgba(185, 255, 193, 0.9) !default;
|
||||
$notification-hover-bg: rgba(160,245,178,0.95) !default;
|
||||
$notification-warning-bg: rgba(228, 255, 78, 0.9) !default;
|
||||
$notification-warning-hover-bg: rgba(218, 245, 68, 0.95) !default;
|
||||
$notification-error-bg: rgba(255, 100, 100, 0.9) !default;
|
||||
$notification-error-hover-bg: rgba(245, 90, 90, 0.95) !default;
|
||||
$notification-border: 1px solid rgba(109, 205, 134, 0.62) !default;
|
||||
$notification-warning-border: 1px solid rgba(205, 205, 109, 0.62) !default;
|
||||
$notification-error-border: 1px solid rgba(205, 109, 109, 0.62) !default;
|
||||
$notification-title-border: 1px solid rgba(83, 158, 102, 0.43) !default;
|
||||
$notification-icon-color: black !default;
|
||||
$notification-warning-icon-color: #662 !default;
|
||||
$notification-error-icon-color: #8b0000 !default;
|
||||
|
||||
//// Loading panel
|
||||
$loading-bg: #909090;
|
||||
|
||||
//// Dashboard
|
||||
$dashboard-bg: url('/img/dashboard-bg-light.jpg')
|
66
platypush/backend/http/webapp/src/utils/Api.vue
Normal file
66
platypush/backend/http/webapp/src/utils/Api.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
name: "Api",
|
||||
methods: {
|
||||
execute(request, timeout=60000) {
|
||||
const opts = {};
|
||||
|
||||
if (!('target' in request) || !request['target']) {
|
||||
request['target'] = 'localhost'
|
||||
}
|
||||
|
||||
if (!('type' in request) || !request['type']) {
|
||||
request['type'] = 'request'
|
||||
}
|
||||
|
||||
// TODO Proper auth/token management
|
||||
// if (window.config.token) {
|
||||
// opts.headers = {
|
||||
// 'X-Token': window.config.token
|
||||
// }
|
||||
// }
|
||||
|
||||
if (timeout) {
|
||||
opts.timeout = timeout
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
axios.post('/execute', request, opts)
|
||||
.then((response) => {
|
||||
response = response.data.response
|
||||
if (!response.errors.length) {
|
||||
resolve(response.output);
|
||||
} else {
|
||||
const error = response.errors[0]
|
||||
this.notify({
|
||||
text: error,
|
||||
error: true,
|
||||
})
|
||||
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.notify({
|
||||
text: error,
|
||||
error: true,
|
||||
})
|
||||
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
request(action, args={}, timeout=60000) {
|
||||
return this.execute({
|
||||
type: 'request',
|
||||
action: action,
|
||||
args: args,
|
||||
}, timeout);
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
21
platypush/backend/http/webapp/src/utils/Notification.vue
Normal file
21
platypush/backend/http/webapp/src/utils/Notification.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script>
|
||||
import { bus } from '@/bus';
|
||||
|
||||
export default {
|
||||
name: "Notification",
|
||||
methods: {
|
||||
notify(notification) {
|
||||
bus.emit('notification-create', notification)
|
||||
},
|
||||
|
||||
error(msg) {
|
||||
this.notify({
|
||||
text: msg,
|
||||
error: true,
|
||||
})
|
||||
|
||||
throw msg
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
96
platypush/backend/http/webapp/src/views/Dashboard.vue
Normal file
96
platypush/backend/http/webapp/src/views/Dashboard.vue
Normal file
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<div id="dashboard" class="columns is-mobile" :class="{blurred: loading}" :style="style">
|
||||
<keep-alive v-for="(widget, i) in widgets" :key="i">
|
||||
<Widget :style="widget.style">
|
||||
<component :is="widget.component" v-bind="widget.props" />
|
||||
</Widget>
|
||||
</keep-alive>
|
||||
</div>
|
||||
|
||||
<Loading v-if="loading" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import Utils from '@/Utils'
|
||||
import Loading from "@/components/Loading";
|
||||
import Widget from "@/widgets/Widget";
|
||||
|
||||
export default {
|
||||
name: 'Dashboard',
|
||||
mixins: [Utils],
|
||||
components: {Widget, Loading},
|
||||
data() {
|
||||
return {
|
||||
widgets: [],
|
||||
loading: false,
|
||||
style: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
parseTemplate(name, tmpl) {
|
||||
const node = new DOMParser().parseFromString(tmpl, 'text/xml').childNodes[0]
|
||||
const self = this;
|
||||
this.style = node.attributes.style ? node.attributes.style.nodeValue : undefined;
|
||||
|
||||
[...node.children].forEach((el) => {
|
||||
const component = defineAsyncComponent(
|
||||
() => import(`@/widgets/${el.nodeName}/Index`)
|
||||
)
|
||||
|
||||
const style = el.attributes.style ? el.attributes.style.nodeValue : undefined;
|
||||
const attrs = [...el.attributes].reduce((obj, node) => {
|
||||
if (node.nodeName !== 'style') {
|
||||
obj[node.nodeName] = node.nodeValue
|
||||
}
|
||||
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
self.$options.components[el.nodeName] = component
|
||||
self.widgets.push({
|
||||
component: component,
|
||||
style: style,
|
||||
props: attrs || {},
|
||||
})
|
||||
})
|
||||
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async refreshDashboard() {
|
||||
this.loading = true
|
||||
this.widgets = []
|
||||
const name = this.$route.params.name
|
||||
const template = (await this.request('config.get_dashboard', { name: name }))
|
||||
|
||||
if (!template) {
|
||||
this.error(`Dashboard ${name} not found`)
|
||||
}
|
||||
|
||||
this.parseTemplate(name, template)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.refreshDashboard()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#dashboard {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 1em 1em 0 1em;
|
||||
background: $dashboard-bg;
|
||||
background-size: cover;
|
||||
|
||||
.blurred {
|
||||
filter: blur(0.075em);
|
||||
}
|
||||
}
|
||||
</style>
|
10
platypush/backend/http/webapp/src/views/NotFound.vue
Normal file
10
platypush/backend/http/webapp/src/views/NotFound.vue
Normal file
|
@ -0,0 +1,10 @@
|
|||
<template>
|
||||
<h1>Object not found</h1>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "NotFound"
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
<template>
|
||||
<div class="date-time-weather">
|
||||
<div class="date" v-text="formatDate(now)"></div>
|
||||
<div class="time" v-text="formatTime(now)"></div>
|
||||
|
||||
<h1 class="weather">
|
||||
<skycons :condition="weatherIcon" :paused="animPaused" :size="iconSize" v-if="weatherIcon" />
|
||||
<span class="temperature" v-if="weather">
|
||||
{{ Math.round(parseFloat(weather.temperature)) + '°' }}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div class="summary" v-if="weather && weather.summary" v-text="weather.summary"></div>
|
||||
|
||||
<div class="sensors" v-if="Object.keys(sensors).length">
|
||||
<div class="sensor temperature col-6" v-if="sensors.temperature">
|
||||
<i class="fas fa-thermometer-half"></i>
|
||||
<span class="temperature">
|
||||
{{ parseFloat(sensors.temperature).toFixed(1) + '°' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="sensor humidity col-6" v-if="sensors.humidity">
|
||||
<i class="fa fa-tint"></i>
|
||||
<span class="humidity">
|
||||
{{ parseFloat(sensors.humidity).toFixed(1) + '%' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from "@/Utils";
|
||||
import Skycons from "vue-skycons"
|
||||
|
||||
// Widget to show date, time, weather and temperature information
|
||||
export default {
|
||||
name: 'DateTimeWeather',
|
||||
mixins: [Utils],
|
||||
components: {Skycons},
|
||||
props: {
|
||||
// If false then the weather icon will be animated.
|
||||
// Otherwise, it will be a static image.
|
||||
paused: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
// Size of the weather icon in pixels
|
||||
iconSize: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 50,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
animPaused() {
|
||||
return !!parseInt(this.paused)
|
||||
},
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
weather: undefined,
|
||||
sensors: {},
|
||||
now: new Date(),
|
||||
weatherIcon: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
async refresh() {
|
||||
const weather = (await this.request('weather.darksky.get_hourly_forecast')).data[0]
|
||||
this.onWeatherChange(weather)
|
||||
},
|
||||
|
||||
refreshTime() {
|
||||
this.now = new Date()
|
||||
},
|
||||
|
||||
formatDate(date) {
|
||||
return date.toDateString().substring(0, 10)
|
||||
},
|
||||
|
||||
formatTime(date) {
|
||||
return date.toTimeString().substring(0, 8)
|
||||
},
|
||||
|
||||
onWeatherChange(event) {
|
||||
if (!this.weather)
|
||||
this.weather = {}
|
||||
|
||||
this.weather = {...this.weather, ...event}
|
||||
this.weatherIcon = this.weather.icon
|
||||
},
|
||||
|
||||
onSensorData(event) {
|
||||
if ('temperature' in event.data)
|
||||
this.sensors.temperature = event.data.temperature
|
||||
|
||||
if ('humidity' in event.data)
|
||||
this.sensors.temperature = event.data.humidity
|
||||
},
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
this.refresh()
|
||||
setInterval(this.refresh, 900000)
|
||||
setInterval(this.refreshTime, 1000)
|
||||
|
||||
// TODO
|
||||
// registerEventHandler(this.onWeatherChange, 'platypush.message.event.weather.NewWeatherConditionEvent')
|
||||
// registerEventHandler(this.onSensorData, 'platypush.message.event.sensor.SensorDataChangeEvent')
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.date-time-weather {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 0.1em;
|
||||
|
||||
.date {
|
||||
font-size: 1.3em;
|
||||
height: 10%;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 2em;
|
||||
height: 14%;
|
||||
}
|
||||
|
||||
.weather {
|
||||
height: 25%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 15%;
|
||||
|
||||
.temperature {
|
||||
font-size: 3.1em;
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
height: 28%;
|
||||
}
|
||||
|
||||
.sensors {
|
||||
width: 100%;
|
||||
height: 13%;
|
||||
|
||||
.sensor {
|
||||
padding: 0 0.1em;
|
||||
}
|
||||
|
||||
.humidity {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
34
platypush/backend/http/webapp/src/widgets/Widget.vue
Normal file
34
platypush/backend/http/webapp/src/widgets/Widget.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<div :style="style" class="widget column is-full-mobile is-half-tablet is-one-quarter-desktop">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "Widget",
|
||||
props: ['style'],
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.widget {
|
||||
background: $background-color;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 1em;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 3px 3px 0 rgba(0,0,0,0.16), 0 0 0 1px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet){
|
||||
.widget {
|
||||
height: calc(100% - 1em);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $desktop){
|
||||
.widget {
|
||||
height: calc(50% - 1em);
|
||||
}
|
||||
}
|
||||
</style>
|
24
platypush/backend/http/webapp/vue.config.js
Normal file
24
platypush/backend/http/webapp/vue.config.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
module.exports = {
|
||||
outputDir: "../dist",
|
||||
assetsDir: "static",
|
||||
css: {
|
||||
loaderOptions: {
|
||||
sass: {
|
||||
additionalData: `
|
||||
@import '~bulma';
|
||||
@import "@/style/themes/light.scss";
|
||||
@import "@/style/layout.scss";
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
devServer: {
|
||||
proxy: {
|
||||
'/execute': {
|
||||
target: 'http://localhost:8008',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -79,6 +79,10 @@ class Config(object):
|
|||
self._config['scripts_dir'] = os.path.join(os.path.dirname(cfgfile), 'scripts')
|
||||
os.makedirs(self._config['scripts_dir'], mode=0o755, exist_ok=True)
|
||||
|
||||
if 'dashboards_dir' not in self._config:
|
||||
self._config['dashboards_dir'] = os.path.join(os.path.dirname(cfgfile), 'dashboards')
|
||||
os.makedirs(self._config['dashboards_dir'], mode=0o755, exist_ok=True)
|
||||
|
||||
init_py = os.path.join(self._config['scripts_dir'], '__init__.py')
|
||||
if not os.path.isfile(init_py):
|
||||
with open(init_py, 'w') as f:
|
||||
|
@ -136,10 +140,12 @@ class Config(object):
|
|||
self.procedures = {}
|
||||
self.constants = {}
|
||||
self.cronjobs = {}
|
||||
self.dashboards = {}
|
||||
|
||||
self._init_constants()
|
||||
self._load_scripts()
|
||||
self._init_components()
|
||||
self._init_dashboards(self._config['dashboards_dir'])
|
||||
|
||||
@staticmethod
|
||||
def _is_special_token(token):
|
||||
|
@ -265,6 +271,38 @@ class Config(object):
|
|||
for (key, value) in self._default_constants.items():
|
||||
self.constants[key] = value
|
||||
|
||||
@staticmethod
|
||||
def get_dashboard(name: str, dashboards_dir: Optional[str] = None) -> Optional[str]:
|
||||
global _default_config_instance
|
||||
|
||||
# noinspection PyProtectedMember,PyProtectedMember,PyUnresolvedReferences
|
||||
dashboards_dir = dashboards_dir or _default_config_instance._config['dashboards_dir']
|
||||
abspath = os.path.join(dashboards_dir, name + '.xml')
|
||||
if not os.path.isfile(abspath):
|
||||
return
|
||||
|
||||
with open(abspath, 'r') as fp:
|
||||
return fp.read()
|
||||
|
||||
@classmethod
|
||||
def get_dashboards(cls, dashboards_dir: Optional[str] = None) -> dict:
|
||||
global _default_config_instance
|
||||
dashboards = {}
|
||||
# noinspection PyProtectedMember,PyProtectedMember,PyUnresolvedReferences
|
||||
dashboards_dir = dashboards_dir or _default_config_instance._config['dashboards_dir']
|
||||
for f in os.listdir(dashboards_dir):
|
||||
abspath = os.path.join(dashboards_dir, f)
|
||||
if not os.path.isfile(abspath) or not abspath.endswith('.xml'):
|
||||
continue
|
||||
|
||||
name = f.split('.xml')[0]
|
||||
dashboards[name] = cls.get_dashboard(name, dashboards_dir)
|
||||
|
||||
return dashboards
|
||||
|
||||
def _init_dashboards(self, dashboards_dir: str):
|
||||
self.dashboards = self.get_dashboards(dashboards_dir)
|
||||
|
||||
@staticmethod
|
||||
def get_backends():
|
||||
global _default_config_instance
|
||||
|
|
19
platypush/plugins/config.py
Normal file
19
platypush/plugins/config.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from platypush import Config
|
||||
from platypush.plugins import Plugin, action
|
||||
|
||||
|
||||
class ConfigPlugin(Plugin):
|
||||
@action
|
||||
def get(self) -> dict:
|
||||
return Config.get()
|
||||
|
||||
@action
|
||||
def dashboards(self) -> dict:
|
||||
return Config.get_dashboards()
|
||||
|
||||
@action
|
||||
def get_dashboard(self, name: str) -> str:
|
||||
return Config.get_dashboard(name)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
Loading…
Reference in a new issue