API tutoriel 4 : créer un dashboard utilisant l'api
Prérequis
une plateforme Spinalcore fonctionnelle
un jumeau numérique initialisé avec un contexte géographique paramétré (voir Créer un contexte géographique )
une heatmap paramétrée (voir Gérer une heatmap ) et ajouter au profil de point de point de contrôle 'Occupation'
le serveur d'API Spinalcore déployé
notions de Vue JS, Vuex
je vous conseille d'utiliser node 16 pour l'installation et node 14 pour l'exécution
Objectifs
Nous avons deux objectifs dans ce tutoriel :
utiliser l'api pour récupérer les informations remontées par les capteurs
créer un site avec vue js pour visualiser ces informations
Initialisation du projet:
Ce site a été construit en utilisant le template Vue Material Dashboard Pro Creative Tim. Une version gratuite est également disponible sur leur site.
Récupérez le template et dézippez-le où vous voulez
Placez vous dans le projet et lancer:
npm install
npm install --save axios
npm install --save vuex
Vous pouvez tester votre apllication avec : npm run serve
Utiliser l'api pour récuperer les informations remontées par les capteurs
Requêtes utiles
GET api/v1/geographicContext/space
GET api/v1/room/{dynamic_id}/control_endpoint_list
Ces API permettent respectivement :
de récupérer l'organisation des espaces du bâtiment (bâtiment/étage/pièces)
de récupérer, pour chaque pièce, la liste des indicateurs disponibles sur la pièce (les "control_endpoint" correspondent aux indicateurs)
Axios
Axios est un module qui permet de requêter des api
Créez le dossier src/api
Dans le nouveau dossier créez lefichier index.js contenant le code suivant
const axios = require('axios');
const path = 'http://{host}:{port}/api/v1/';
const API = {
async getBuildingAsync() {
const requestUrl = `${path}geographicContext/space`;
const result = await axios.get(requestUrl);
const body = result.data;
return body.children.find(b => b.type == "geographicBuilding");
},
async getRoomControlEndpointsListAsync(roomId) {
const requestUrl = `${path}room/${roomId}/control_endpoint_list`;
const result = await axios.get(requestUrl);
return result.data[0];
}
}
module.exports = API;
Vuex
Vuex permet la gestion d'un data store partagé par l'ensemble de l'application
Créer le fichier ayant pour chemin src/store/index.js et copier le code suivant
import Vue from 'vue'
import Vuex from 'vuex'
import { getBuildingAsync, getRoomControlEndpointsListAsync } from '../api'
Vue.use(Vuex)
const store = new Vuex.Store({
state: {
buildingName: 'Nom du bâtiment',
floors: []
},
mutations: {
SET_BUILDING_NAME(state, paylode) {
state.buildingName = String(paylode);
},
SET_FLOORS(state, paylode) {
state.floors = paylode;
}
},
actions: {
// initialise le nom du batiment et l'ensemble des pièces
async initializeBuilding({ commit }) {
try {
let building = await getBuildingAsync();
for (var f of building.children) {
// récupération de l'état des pièces
for (var r of f.children) {
const control_endpoint_list = await getRoomControlEndpointsListAsync(r.dynamicId);
if (control_endpoint_list) {
control_endpoint_list.endpoints.forEach(e => {
const mesure = e.type;
const valeur = e.currentValue;
r[mesure.toLowerCase()] = valeur;
});
}
}
}
commit('SET_BUILDING_NAME', building.name);
commit('SET_FLOORS', building.children);
} catch (error) {
return;
}
},
// met à jour l'états des pièces
async udpateBuilding(param) {
const floors = param.state.floors;
let building = floors;
try {
for (var f of building) {
// récupération de l'état des pièces
for (var r of f.children) {
const control_endpoint_list = await getRoomControlEndpointsListAsync(r.dynamicId);
if (control_endpoint_list) {
control_endpoint_list.endpoints.forEach(e => {
const mesure = e.type;
const valeur = e.currentValue;
r[mesure.toLowerCase()] = valeur;
});
}
}
}
param.commit('SET_FLOORS', building);
} catch(e) {
return
}
}
},
getters: {
// calcule et retourne le taux d'occupation d'un étage
occupationRate: (state) => (paylode) => {
let sum = 0;
const rooms = state.floors.find(f => f.dynamicId == Number(paylode)).children;
rooms.forEach(r => {
if (r.occupation) {
sum++;
}
});
sum *= 100;
const avg = parseInt(sum / rooms.length, 10);
return avg;
},
// calcule et retourne la température moyenne d'un étage
floorTemperature: (state) => (paylode) => {
let sum = 0;
const rooms = state.floors.find(f => f.dynamicId == Number(paylode)).children;
rooms.forEach(r => {
if (r.temperature) {
sum+=r.temperature;
}
});
const avg = parseInt(sum / rooms.length, 10);
return avg;
}
// vous avez la méthode pour calculer la moyenne sur n'importe quel point de contrôle
},
modules: {
}
});
export default store;
Importer le store dans main.js
import store from "./store";
Vue.use(store);
new Vue({
el: "#app",
store, // seule cette ligne est à ajouter
render: h => h(App),
router,
// version gratuite
data: {
Chartist: Chartist,
},
});
Créer un dashboard avec vue js pour visualiser ces informations
Composants
Vous avez quatres composants (fichiers) à créer dans pages/Components/Pages (directement dans pages pour la version gratuite)
Room.vue : Affiche la valeur d'une salle pour un type de point de contrôle donné (par FloorDetails)
<template>
<div class="md-layout-item md-medium-size-100 md-xsmall-size-100 md-size-33">
<stats-card header-color="blue">
<template slot="header">
<div class="card-icon">
<h4 v-if="mesure === 'occupation'">{{ String(valeur).toUpperCase() }}</h4>
<h4 v-else>{{ parseInt(valeur, 10) }} °C</h4>
</div>
<h3>{{ name }}</h3>
</template>
<template slot="footer">
<div class="stats">
<md-icon>access_time</md-icon>
mis à jour toutes les minutes
</div>
</template>
</stats-card>
</div>
</template>
<script>
import { StatsCard } from '@/components'
export default {
name: 'Room',
components: {
StatsCard
},
props: {
id: {
type: Number,
required: true
},
name: {
type: String,
required: true
},
mesure: {
type: String,
required: true
},
valeur: {
required: true
}
}
}
</script>
FloorDetails.vue : Affiche la mesure moyenne d'un étage et le détail des salles pour un type de point de contrôle donné
<template>
<div>
<div id="floor-info">
<h2>{{ name }} :</h2>
<h3>
{{ mesure.toUpperCase() }}:
<span v-if="mesure === 'occupation'">{{ occupationRate(this.id) }}%</span>
<span v-else>{{ floorTemperature(this.id) }} °C</span>
</h3>
</div>
<div id="rooms">
<Room v-for="room in rooms"
:id="room.dynamicId"
:name="room.name"
:mesure="mesure"
:valeur="room[mesure]"
:key="room.dynamicId"
/>
</div>
</div>
</template>
<script>
import Room from './Room'
import { mapGetters } from 'vuex'
export default {
name: 'FloorDetails',
components: {
Room,
},
props: {
id: {
type: Number,
required: true
},
name: {
type: String,
required: true,
},
mesure: {
type: String,
required: true
},
rooms: Array
},
computed: {
...mapGetters(["occupationRate", "floorTemperature"])
}
}
</script>
<style scoped lang="scss">
#floor-info {
display: flex;
flex-direction: row;
justify-content: space-between;
}
h2 {
font-weight: bold;
}
#rooms{
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
</style>
FloorGeneral.vue : Affiche les information pour un étage sur l'ensemble des points de contrôle
<template>
<div id="floor">
<h2>{{ name }}</h2>
<div id="floor-info">
<div
class="md-layout-item md-medium-size-100 md-xsmall-size-100 md-size-33"
>
<stats-card header-color="rose">
<template slot="header">
<div class="card-icon">
<h3 class="title">{{ occupationRate(this.id) }}%</h3>
</div>
<h4>Occupation</h4>
<!--Bouton pour afficher le détail de l'étage (occupation)-->
<md-button class="md-simple md-info md-just-icon" @click="emitSeeDetails('occupation')">
<md-icon>art_track</md-icon>
<md-tooltip md-direction="bottom">Détails</md-tooltip>
</md-button>
</template>
<template slot="footer">
<div class="stats">
<md-icon>access_time</md-icon>
mis à jour toutes les minutes
</div>
</template>
</stats-card>
</div>
<div
class="md-layout-item md-medium-size-100 md-xsmall-size-100 md-size-33"
>
<stats-card header-color="green">
<template slot="header">
<div class="card-icon">
<h3 class="title">{{ floorTemperature(this.id) }} °C</h3>
</div>
<h4>Temperature</h4>
<!--Bouton pour afficher le détail de l'étage (température)-->
<md-button class="md-simple md-info md-just-icon" @click="emitSeeDetails('temperature')">
<md-icon>art_track</md-icon>
<md-tooltip md-direction="bottom">Détails</md-tooltip>
</md-button>
</template>
<template slot="footer">
<div class="stats">
<md-icon>access_time</md-icon>
mis à jour toutes les minutes
</div>
</template>
</stats-card>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { StatsCard } from '@/components'
export default {
name: 'FloorGeneral',
components: {
StatsCard
},
props: {
id: {
type: Number,
required: true
},
name: {
type: String,
required: true,
},
rooms: Array
},
computed: {
...mapGetters(["occupationRate", "floorTemperature"])
},
methods: {
emitSeeDetails(paylode) {
this.$emit('seeDetails', { id: this.id, mesure: String(paylode) })
}
}
}
</script>
<style lang="scss">
#floor {
border: solid 1px;
border-radius: 5px;
margin: 5px;
}
h2 {
font-style: italic;
font-weight: bold;
}
#floor-info {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
</style>
Home.vue : Page principale qui permet un switch entre FloorGeneral et FloorDetails
<template>
<div class="home">
<h1>{{ buildingName.toUpperCase() }}</h1>
<div v-if="general">
<FloorGeneral v-for="floor in floors"
@seeDetails="displayFloor"
:id="floor.dynamicId"
:name="floor.name"
:rooms="floor.children"
:key="floor.name"
/>
</div>
<div v-else>
<md-button class="md-warning" @click="displayGeneral">
<md-icon>arrow_back</md-icon>
Retour à l'affichage général
</md-button>
<FloorDetails
:id="detailedFloor.dynamicId"
:name="detailedFloor.name"
:rooms="detailedFloor.children"
:mesure="mesure"
:key="detailedFloor.name"
/>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex'
import FloorGeneral from './FloorGeneral.vue'
import FloorDetails from './FloorDetails.vue'
export default {
name: 'Home',
components: {
FloorGeneral,
FloorDetails
},
data() {
return {
general: true,
mesure: 'Occupation',
detailedFloor: null
}
},
computed: {
...mapState(["buildingName", "floors"])
},
methods:{
displayFloor(paylode){
this.general = false;
this.mesure = paylode.mesure;
this.detailedFloor = this.floors.find(f => f.dynamicId == paylode.id);
},
displayGeneral() {
this.general = true;
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h1 {
font-weight: bold;
}
</style>
Routeur
Dans le fichier src/pages/Dashboard/Layout/DashboardLayout.vue
Dans template
Commentez toutes les balises <sidebar-item> sauf "dashboard" et ajouter la balises suivantes
<div @click="downloadCSV">
<!-- Si vous utilisez la version pro -->
<sidebar-item
:link="{ name: 'download', icon: 'download', path: '/'}"
>
</sidebar-item>
<!---->
<!-- Si vous utilisez la version gratuite -->
<sidebar-link to="/">
<md-icon>download</md-icon>
<p>Download</p>
</sidebar-link>
<!---->
</div>
Dans srcipt
ajouter les lignes suivantes
let interId;
import { mapState, mapActions } from 'vuex'
dans computed récuperer les éléments utiles du data store
...mapActions(["buildingName", "floors"]),
dans l'attribut methods ajouter la fonction
...mapActions(["initializeBuilding", "udpateBuilding"]),
// téléchargement des information du batiment au format csv
downloadCSV(e) {
e.preventDefault();
// creation d'un tableau de string contenant les infos (et séparateurs)
let tableur = ['Etage,', 'Salle,', 'Occupation,', 'Température', '\n'];
this.floors.forEach(f => {
f.children.forEach(r => {
tableur.push(
f.name,
',',
r.name,
',',
r.occupation,
',',
parseInt(r.temperature, 10),
'\n'
)
})
});
// création d'une balise type <a></a> et définition des attribut
const a = document.createElement('a');
// Attribut:
// lien vers un "fichier" créer dynamiquement
a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(tableur.join(''));
a.target = "_blank";
// dowload indique que le lien est à télécharger. le nom une fois téléchargé est donné comme valeur
a.download = `${this.buildingName}.csv`;
// déclenche l'événement onClick sur le lien
a.click();
}
à la suite de methods
beforeMount() {
this.initializeBuilding();
},
// ATTETION MOUNTED() EXISTE DÉJÀ DANS LA VERSION PRO
mounted() {
reinitScrollbar();
interId = setInterval(this.udpateBuilding, 60000); // ajouter uniquement cette ligne
},
beforeUnmount() {
clearInterval(interId);
}
Dans src/routes/index.js
Commentez l'import de dashboard et importez le composant Home
//import Dashboard from "@/pages/Dashboard.vue";
import Home from "@/pages/Home.vue"
dans routes remplacez le composant Dashboard par Home
children: [
{
path: "dashboard",
name: "Dashboard",
component: Home, // ici
},
Vous avez normalement un dashboard fonctionnle. N'oubliez pas de lancer votre Spinalcore pour tester votre site. Libre à vous d'y apporter vos modifications. Amusez-vous bien ;)