Merge branch 'feat/8-frontend'

Frontend and http client

See merge request team-raclette/project-softweng!11
This commit is contained in:
Yann Sierro
2025-05-20 08:08:56 +00:00
18 changed files with 1153 additions and 3631 deletions

3
.gitignore vendored
View File

@@ -92,3 +92,6 @@ fabric.properties
.idea/caches/build_file_checksums.ser .idea/caches/build_file_checksums.ser
config.json config.json
.env
setup_env.sh

View File

@@ -90,8 +90,7 @@ services:
environment: environment:
- RABBITMQ_DEFAULT_USER=$MQTT_USERNAME - RABBITMQ_DEFAULT_USER=$MQTT_USERNAME
- RABBITMQ_DEFAULT_PASS=$MQTT_PASSWORD - RABBITMQ_DEFAULT_PASS=$MQTT_PASSWORD
command: command: sh -c "rabbitmq-plugins enable rabbitmq_mqtt && rabbitmq-server"
sh -c "rabbitmq-plugins enable rabbitmq_mqtt && rabbitmq-server"
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.mqtt-http.entrypoints=http" - "traefik.http.routers.mqtt-http.entrypoints=http"
@@ -101,3 +100,24 @@ services:
- "traefik.http.routers.mqtt-https.rule=Host(`mqtt.mse.kb28.ch`)" - "traefik.http.routers.mqtt-https.rule=Host(`mqtt.mse.kb28.ch`)"
- "traefik.http.routers.mqtt-https.tls.certResolver=letsencrypt" - "traefik.http.routers.mqtt-https.tls.certResolver=letsencrypt"
- "traefik.http.services.mqtt-https.loadbalancer.server.port=1883" - "traefik.http.services.mqtt-https.loadbalancer.server.port=1883"
web-app:
image: web-app:1.0
container_name: web-app
restart: unless-stopped
ports:
- "8080:8080"
environment:
- VUE_APP_INFLUXDB_USER=$REST_USERNAME
- VUE_APP_INFLUXDB_PASSWORD=$REST_PASSWORD
labels:
- "traefik.enable=true"
- "traefik.http.routers.web-app-http.entrypoints=http"
- "traefik.http.routers.web-app-http.rule=Host(`app.mse.kb28.ch`)"
- "traefik.http.middlewares.web-app-redirect.redirectscheme.scheme=https"
- "traefik.http.routers.web-app-https.entrypoints=https"
- "traefik.http.routers.web-app-https.rule=Host(`app.mse.kb28.ch`)"
- "traefik.http.routers.web-app-https.tls.certResolver=letsencrypt"
- "traefik.http.services.web-app-https.loadbalancer.server.port=8080"

View File

@@ -0,0 +1,66 @@
@startuml web-app
skinparam linetype ortho
class App {
' principale vuejs component
}
class Client {
+getValues()
+getNewValue()
}
class SeriesManager {
' get data, put data in graphs, ask new measure
+getNewValue()
+getAllValue()
}
class SerieConverter {
' convert timeseries from backend for graphs
+jsonToSerie()
}
class TimeSeries {
' simplify the duplication of series in graphs
-Series
}
class Button {
' vue js component
' ask new measure
}
class Serie {
' contains table of measure
' convert the table to graphs format
-type
-values
-numberOfLastValus
-id
-place
-name
+getGraphFormat()
' needs to limit the number of value
+addNewValue()
}
class Plot {
}
App *-r- SeriesManager
App *-u- Client
SeriesManager *-r- TimeSeries
Plot -l-* TimeSeries
Serie -u-* TimeSeries
Serie *-- Button
SerieConverter .u.> SeriesManager
@enduml

View File

@@ -1,5 +0,0 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

11
web-app/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:22.15-slim
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 8080
CMD ["npm", "run", "serve"]

View File

@@ -1,21 +1,11 @@
# web-app # web-app
## Installation
Install npm and NodeJS from [here](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
## Project setup ## Project setup
Install all dependencies of the npm project. It needs to have [Vue](https://cli.vuejs.org/guide/installation.html) in global
``` ```
sudo npm install -g @vue/cli
npm install npm install
``` ```
If you have any problem try the next command :
```
npm install ./ --legacy-peer-deps
```
### Compiles and hot-reloads for development ### Compiles and hot-reloads for development
Run a preview on localhost
``` ```
npm run serve npm run serve
``` ```
@@ -25,11 +15,5 @@ npm run serve
npm run build npm run build
``` ```
### Lints and fixes files
Run the linter integrated
```
npm run lint
```
### Customize configuration ### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/). See [Configuration Reference](https://cli.vuejs.org/config/).

3852
web-app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,44 +3,23 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve --env-mode",
"build": "vue-cli-service build", "build": "vue-cli-service build"
"lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"axios": "^1.9.0",
"chartjs": "^0.3.24",
"dotenv": "^16.5.0",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-class-component": "^8.0.0-0" "vue-chartjs": "^5.3.2",
"vue-multiselect": "^3.2.0",
"vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-typescript": "~5.0.0", "@vue/cli-plugin-typescript": "~5.0.0",
"@vue/cli-service": "~5.0.0", "@vue/cli-service": "~5.0.0",
"@vue/eslint-config-standard": "^6.1.0",
"@vue/eslint-config-typescript": "^9.1.0",
"eslint": "^7.32.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-vue": "^8.0.3",
"typescript": "~4.5.5" "typescript": "~4.5.5"
}, },
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"@vue/standard",
"@vue/typescript/recommended"
],
"parserOptions": {
"ecmaVersion": 2020
},
"rules": {}
},
"browserslist": [ "browserslist": [
"> 1%", "> 1%",
"last 2 versions", "last 2 versions",

View File

@@ -1,27 +1,155 @@
<template> <template>
<img alt="Vue logo" src="./assets/logo.png"> <div class="app-container">
<HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/> <!-- Header Section -->
<header class="header">
<h1>Home Monitor</h1>
</header>
<!-- Main Content Section -->
<div class="main-content">
<!-- Left Sidebar with Controls -->
<div class="sidebar">
<ControlPanel
:show-temperature.sync="showTemperature"
:show-humidity.sync="showHumidity"
@add-temperature="addTemperature"
@refresh="refreshData"
:manager="manager"
/>
</div>
<!-- Right Side Chart Area -->
<div class="chart-area">
<ChartComponent
:series="series"
:show-temperature="showTemperature"
:show-humidity="showHumidity"
:loading="loading"
:error="error"
/>
</div>
</div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Options, Vue } from 'vue-class-component' import { defineComponent } from "vue";
import HelloWorld from './components/HelloWorld.vue' import ChartComponent from "./components/ChartComponent.vue";
import ControlPanel from "./components/ControlPanel.vue";
import { HttpClient } from "./Services/HttpClient";
import { TimeSeriesManager } from "./Measures/TimeSeriesManager";
import { URL, USERNAME, PASSWORD, USER, ROOM, DEVICE, APP_NAME } from "./const";
import { Serie } from "./Measures/Serie";
@Options({ let httpClient = new HttpClient(URL, USERNAME, PASSWORD);
let manager = new TimeSeriesManager(httpClient);
export default defineComponent({
name: APP_NAME,
components: { components: {
HelloWorld ChartComponent,
} ControlPanel,
},
data() {
return {
manager,
series: [] as any[],
loading: true,
error: undefined as string | undefined,
showTemperature: true,
showHumidity: true,
};
},
methods: {
addTemperature() {
// Implement your functionality here
console.log("Add temperature clicked");
manager
.getNewValue(USER, ROOM, DEVICE)
.then((result) => {})
.catch((error) => {
console.error("Error asking data:", error);
this.error = "Failed to load data. Please try again later.";
this.loading = false;
});
},
refreshData() {
console.log("Refreshing data...");
this.fetchData();
},
fetchData() {
this.loading = true;
this.error = undefined;
manager
.getTimeSeriesData(USER, ROOM, DEVICE)
.then((result) => {
this.series = result;
this.loading = false;
}) })
export default class App extends Vue {} .catch((error) => {
console.error("Error fetching data:", error);
this.error = "Failed to load data. Please try again later.";
this.loading = false;
});
},
},
mounted() {
// Fetch data when component is mounted
this.fetchData();
},
});
</script> </script>
<style> <style>
#app { .app-container {
font-family: Avenir, Helvetica, Arial, sans-serif; display: grid;
-webkit-font-smoothing: antialiased; grid-template-rows: auto 1fr;
-moz-osx-font-smoothing: grayscale; height: 100vh;
width: 100%;
overflow: hidden;
}
.header {
padding: 1rem;
background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
text-align: center; text-align: center;
color: #2c3e50; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
margin-top: 60px; }
.main-content {
display: grid;
grid-template-columns: 250px 1fr;
height: 100%;
overflow: hidden;
}
.sidebar {
background-color: #f8f9fa;
border-right: 1px solid #e9ecef;
padding: 1.25rem;
overflow-y: auto;
}
.chart-area {
padding: 1.5rem;
height: 100%;
overflow-y: auto;
}
@media (max-width: 768px) {
.main-content {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid #e9ecef;
padding: 1rem;
}
} }
</style> </style>

View File

@@ -0,0 +1,52 @@
import { TEMPERATURE, HUMIDITY, TYPE, VALUE } from "../const";
export class Serie {
private _type: string;
private _data: { time: number; value: number }[];
private _user: string;
private _room: string;
private _device: string;
constructor(
type: string,
data: { time: number; value: number }[],
user: string,
room: string,
device: string
) {
this._type = type;
this._data = data;
this._user = user;
this._room = room;
this._device = device;
}
public getLabel(): string {
return `${this._type} - ${this._user} - ${this._room} - ${this._device}`;
}
public getSerie(): any {
if (this._type === TEMPERATURE) {
return {
label: this.getLabel(),
data: this._data.map((v: any) => {
return { x: v.time, y: v.value };
}),
borderColor: "rgba(255, 99, 132, 1)",
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderWidth: 1,
};
} else if (this._type === HUMIDITY) {
return {
label: this.getLabel(),
data: this._data.map((v: any) => {
return { x: v.time, y: v.value };
}),
borderColor: "rgba(54, 162, 235, 1)",
backgroundColor: "rgba(54, 162, 235, 0.2)",
borderWidth: 1,
};
}
}
}

View File

@@ -0,0 +1,142 @@
import { Prop } from "vue";
import { Serie } from "./Serie";
import { HttpClient } from "../Services/HttpClient";
import { TEMPERATURE, HUMIDITY, TYPE, VALUE } from "../const";
import { ref } from "vue";
export class TimeSeriesManager {
private client: HttpClient;
selected_user = {
user: "Rémi",
tag: "remi",
};
selected_room = {
room: "Bedroom",
tag: "Bedroom",
};
selected_device = {
device: "Door sensor",
tag: "DoorSensor",
};
user_options = [
{
user: "Rémi",
tag: "remi",
},
{
user: "Sylvan",
tag: "sylvan",
},
];
room_options = [
{
room: "23N309",
tag: "23N309",
},
{
room: "Bedroom",
tag: "Bedroom",
},
{
room: "Terrasse",
tag: "Terrasse",
},
];
device_options = [
{
device: "Door sensor",
tag: "DoorSensor",
},
{
device: "Shed",
tag: "Shed",
},
];
constructor(client: HttpClient) {
this.client = client;
}
async getTimeSeriesData(
user: string,
room: string,
device: string
): Promise<Serie[]> {
return this.client
.getValues(
this.selected_user.tag,
this.selected_room.tag,
this.selected_device.tag
)
.then((response) => {
// Filter temperature records
let temperatureRecords = response.data.filter(
(x: any) => x[TYPE] === TEMPERATURE
);
// Create a new array with type field removed and time converted to timestamp
let temperatureRecordsProcessed = temperatureRecords.map((x: any) => {
// Create a new object without the TYPE field
const { [TYPE]: removed, ...rest } = x;
// Convert the ISO time string to a timestamp (if it's a string)
if (typeof rest.time === "string") {
rest.time = new Date(rest.time).getTime();
}
return rest;
});
//filter humidity records
let humidityRecord = response.data.filter(
(x: any) => x[TYPE] === HUMIDITY
);
// Create a new array with type field removed and time converted to timestamp
let humidityRecordProcessed = humidityRecord.map((x: any) => {
// Create a new object without the TYPE field
const { [TYPE]: removed, ...rest } = x;
// Convert the ISO time string to a timestamp (if it's a string)
if (typeof rest.time === "string") {
rest.time = new Date(rest.time).getTime();
}
return rest;
});
// Create actual Serie instances
const temperatureSerie = new Serie(
TEMPERATURE,
temperatureRecordsProcessed,
user,
room,
device
);
const humiditySerie = new Serie(
HUMIDITY,
humidityRecordProcessed,
user,
room,
device
);
return [temperatureSerie, humiditySerie];
})
.catch((error) => {
console.error("Error fetching time series data:", error);
throw error; // Re-throw to allow calling code to handle it
});
}
async getNewValue(user: string, room: string, device: string) {
this.client.newValue(user, room, device).catch((error) => {
console.error("Error asking new values:", error);
throw error;
});
}
}

View File

@@ -0,0 +1,80 @@
import axios, { AxiosResponse } from "axios";
import { BASE, PING, RACLETTE, PONG } from "../const";
export class HttpClient {
private _url: string;
private _username: string | undefined;
private _password: string | undefined;
constructor(
url: string,
username: string | undefined,
password: string | undefined
) {
this._url = url;
this._username = username;
this._password = password;
}
async isConnected(): Promise<boolean> {
var connected = false;
if (await this.ping()) {
connected = true;
console.log("Connected to backend!");
} else {
connected = false;
console.log("Connection failed backend!");
}
return connected;
}
private async ping(): Promise<string> {
const response = await axios.get(`${BASE}${this._url}/${PING}`);
return response.data;
}
private getAuthHeader() {
return {
Authorization: `Basic ${btoa(`${this._username}:${this._password}`)}`,
"Content-Type": "application/json",
};
}
async getValues(
user: string,
room: string,
device: string
): Promise<AxiosResponse<any, any>> {
const response = await axios.get(`${BASE}${this._url}/${RACLETTE}`, {
headers: this.getAuthHeader(),
params: {
user: user,
room: room,
device: device,
},
});
return response;
}
async newValue(
user: string,
room: string,
device: string
): Promise<AxiosResponse<any, any>> {
const response = await axios.post(
`${BASE}${this._url}/${RACLETTE}`,
{
command: "MEASURE_NEW",
},
{
headers: this.getAuthHeader(),
params: {
user: user,
room: room,
device: device,
},
}
);
return response;
}
}

View File

@@ -0,0 +1,111 @@
<!-- src/components/ChartComponent.vue -->
<template>
<div class="chart-wrapper">
<div v-if="loading" class="loading-state">Loading data...</div>
<div v-else-if="error" class="error-state">{{ error }}</div>
<div v-else class="chart-container">
<Scatter
v-if="chartData.datasets.length > 0"
:data="chartData"
:options="chartOptions"
/>
<div v-else class="no-data-state">No data to display</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, proxyRefs } from "vue";
import {
Chart as ChartJS,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
} from "chart.js";
import { Scatter } from "vue-chartjs";
import { Serie } from "../Measures/Serie";
// Register Chart.js components
ChartJS.register(LinearScale, PointElement, LineElement, Tooltip, Legend);
export default defineComponent({
name: "ChartComponent",
components: {
Scatter,
},
props: {
series: {
type: Array as PropType<Serie[]>, // Use PropType to specify array of Serie
default: () => [],
},
showTemperature: {
type: Boolean,
default: true,
},
showHumidity: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String as PropType<string | undefined>,
default: null,
},
},
setup(props) {
const chartData = computed(() => {
let series_prepared = props.series.map((s: Serie) => {
return s.getSerie();
});
return {
datasets: series_prepared,
};
});
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
};
return {
chartOptions,
chartData,
};
},
});
</script>
<style scoped>
.chart-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.chart-container {
flex: 1;
min-height: 300px;
}
.loading-state,
.error-state,
.no-data-state {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
font-size: 1.2rem;
color: #666;
}
.error-state {
color: #dc3545;
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div class="control-panel">
<button @click="$emit('add-temperature')" class="action-button">
Add Temperature
</button>
<button @click="$emit('refresh')" class="refresh-button">
<span class="button-icon"></span> Refresh Data
</button>
</div>
<div>
<span>
<multiselect
id="user-select"
v-model="manager.selected_user"
:options="manager.user_options"
:multiple="false"
:group-select="false"
placeholder="User"
track-by="user"
label="user"
</multiselect>
</span>
<span>
<multiselect
id="room-select"
v-model="manager.selected_room"
:options="manager.room_options"
:multiple="false"
:group-select="false"
placeholder="Room"
track-by="room"
label="room"
</multiselect>
</span>
<span>
<multiselect
id="device-select"
v-model="manager.selected_device"
:options="manager.device_options"
:close-on-select="true"
:multiple="false"
:group-select="false"
placeholder="Device"
track-by="device"
label="device"
</multiselect>
</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Multiselect from "vue-multiselect";
import "vue-multiselect/dist/vue-multiselect.css"; // Add
import { ref } from "vue";
import { TimeSeriesManager } from "@/Measures/TimeSeriesManager";
let options = [{ code: "CA", country: "Canada" }];
export default defineComponent({
name: "ControlPanel",
props: {
showTemperature: {
type: Boolean,
required: true,
},
showHumidity: {
type: Boolean,
required: true,
},
manager: {
type: TimeSeriesManager,
required: true,
}
},
emits: [
"add-temperature",
"update:showTemperature",
"update:showHumidity",
"refresh",
"simulate-data",
],
components: {
Multiselect,
},
methods: {
setSelected(value: any) {
// trigger a mutation, or dispatch an action
},
},
});
</script>
<style scoped>
.control-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 0.5rem;
}
.action-button,
.refresh-button,
.demo-button {
padding: 0.75rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.action-button {
background-color: #3490dc;
color: white;
}
.action-button:hover {
background-color: #2779bd;
}
.refresh-button {
background-color: #38c172;
color: white;
}
.refresh-button:hover {
background-color: #2d995b;
}
.demo-button {
background-color: #f6993f;
color: white;
}
.demo-button:hover {
background-color: #e67e22;
}
.button-icon {
margin-right: 0.5rem;
font-size: 1.2rem;
}
.sensor-selector h3 {
margin-top: 0;
margin-bottom: 0.75rem;
font-size: 1.1rem;
color: #333;
}
.sensor-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sensor-option {
display: flex;
align-items: center;
cursor: pointer;
}
.sensor-option input {
margin-right: 0.5rem;
cursor: pointer;
}
</style>

View File

@@ -9,7 +9,6 @@
<h3>Installed CLI Plugins</h3> <h3>Installed CLI Plugins</h3>
<ul> <ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript" target="_blank" rel="noopener">typescript</a></li> <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript" target="_blank" rel="noopener">typescript</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
</ul> </ul>
<h3>Essential Links</h3> <h3>Essential Links</h3>
<ul> <ul>
@@ -31,16 +30,14 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Options, Vue } from 'vue-class-component' import { defineComponent } from 'vue';
@Options({ export default defineComponent({
name: 'HelloWorld',
props: { props: {
msg: String msg: String,
} },
}) });
export default class HelloWorld extends Vue {
msg!: string
}
</script> </script>
<!-- Add "scoped" attribute to limit CSS to this component only --> <!-- Add "scoped" attribute to limit CSS to this component only -->

21
web-app/src/const.ts Normal file
View File

@@ -0,0 +1,21 @@
export const APP_NAME = "Home Monitor";
export const URL = "rest.mse.kb28.ch";
// load environment varaibles - need to have the prefix VUE_APP
export const USERNAME = process.env.VUE_APP_INFLUXDB_USER;
export const PASSWORD = process.env.VUE_APP_INFLUXDB_PASSWORD;
export const HUMIDITY = "humidity";
export const TEMPERATURE = "temperature";
export const TYPE = "type";
export const VALUE = "value";
export const USER = "remi";
export const ROOM = "Terrasse";
export const DEVICE = "Shed";
export const BASE = "https://";
export const PING = "ping";
export const RACLETTE = "raclette";
export const PONG = "pong";

View File

@@ -1,6 +0,0 @@
/* eslint-disable */
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -6,7 +6,6 @@
"jsx": "preserve", "jsx": "preserve",
"importHelpers": true, "importHelpers": true,
"moduleResolution": "node", "moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,