New Vue.js template for dashbord WIP

This commit is contained in:
Fabio Manganiello 2020-11-21 01:12:08 +01:00
parent 0c0e7411f7
commit 39abdfe40a
32 changed files with 13983 additions and 20 deletions

View file

@ -10,8 +10,8 @@ from platypush.backend.http.app.utils import get_routes
base_folder = os.path.abspath(os.path.join( base_folder = os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)), '..')) os.path.dirname(os.path.abspath(__file__)), '..'))
template_folder = os.path.join(base_folder, 'templates') template_folder = os.path.join(base_folder, 'dist')
static_folder = os.path.join(base_folder, 'static') static_folder = os.path.join(base_folder, 'dist/static')
application = Flask('platypush', template_folder=template_folder, application = Flask('platypush', template_folder=template_folder,
static_folder=static_folder) static_folder=static_folder)

View file

@ -1,10 +1,8 @@
from flask import Blueprint, request, render_template 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.app.utils import authenticate, get_websocket_port
from platypush.backend.http.utils import HttpUtils from platypush.backend.http.utils import HttpUtils
from platypush.config import Config
dashboard = Blueprint('dashboard', __name__, template_folder=template_folder) 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() @authenticate()
def dashboard(): def render_dashboard(*_, **__):
""" Route for the fullscreen dashboard """ """ Route for the dashboard """
http_conf = Config.get('backend.http') return render_template('index.html',
dashboard_conf = http_conf.get('dashboard', {}) utils=HttpUtils,
websocket_port=get_websocket_port())
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)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -7,7 +7,7 @@ from platypush.config import Config
from platypush.backend.http.app import template_folder, static_folder 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) resources = Blueprint('resources', __name__, template_folder=template_folder)
favicon = Blueprint('favicon', __name__, template_folder=template_folder) favicon = Blueprint('favicon', __name__, template_folder=template_folder)
img = Blueprint('img', __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']) @favicon.route('/favicon.ico', methods=['GET'])
def favicon(): def serve_favicon():
""" favicon.ico icon """ """ 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']) @img.route('/img/<path:path>', methods=['GET'])
def imgpath(path): def imgpath(path):

View file

@ -1 +0,0 @@
Place in this folder the static resources you want to serve over HTTP (images, fonts etc.)

View 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?

View 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/).

View file

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

File diff suppressed because it is too large Load diff

View 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"
]
}

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View 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>

View 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>

View 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>

View file

@ -0,0 +1,9 @@
<script>
import Api from "@/utils/Api";
import Notification from "@/utils/Notification";
export default {
name: "Utils",
mixins: [Api, Notification],
}
</script>

View file

@ -0,0 +1,5 @@
import mitt from 'mitt';
const bus = mitt();
export { bus };

View 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>

View 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>

View file

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

View 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')

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

View 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;
}

View 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')

View 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>

View 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>

View 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>

View file

@ -0,0 +1,10 @@
<template>
<h1>Object not found</h1>
</template>
<script>
export default {
name: "NotFound"
}
</script>

View file

@ -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)) + '&deg;' }}
</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> &nbsp;
<span class="temperature">
{{ parseFloat(sensors.temperature).toFixed(1) + '&deg;' }}
</span>
</div>
<div class="sensor humidity col-6" v-if="sensors.humidity">
<i class="fa fa-tint"></i> &nbsp;
<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>

View 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>

View 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,
}
}
}
};

View file

@ -79,6 +79,10 @@ class Config(object):
self._config['scripts_dir'] = os.path.join(os.path.dirname(cfgfile), 'scripts') self._config['scripts_dir'] = os.path.join(os.path.dirname(cfgfile), 'scripts')
os.makedirs(self._config['scripts_dir'], mode=0o755, exist_ok=True) 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') init_py = os.path.join(self._config['scripts_dir'], '__init__.py')
if not os.path.isfile(init_py): if not os.path.isfile(init_py):
with open(init_py, 'w') as f: with open(init_py, 'w') as f:
@ -136,10 +140,12 @@ class Config(object):
self.procedures = {} self.procedures = {}
self.constants = {} self.constants = {}
self.cronjobs = {} self.cronjobs = {}
self.dashboards = {}
self._init_constants() self._init_constants()
self._load_scripts() self._load_scripts()
self._init_components() self._init_components()
self._init_dashboards(self._config['dashboards_dir'])
@staticmethod @staticmethod
def _is_special_token(token): def _is_special_token(token):
@ -265,6 +271,38 @@ class Config(object):
for (key, value) in self._default_constants.items(): for (key, value) in self._default_constants.items():
self.constants[key] = value 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 @staticmethod
def get_backends(): def get_backends():
global _default_config_instance global _default_config_instance

View 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: