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.

  1. Récupérez le template et dézippez-le où vous voulez

  2. 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 ;)