diff --git a/.env.template b/.env.template index 3cae7b0..e267436 100644 --- a/.env.template +++ b/.env.template @@ -7,4 +7,6 @@ MQTT_URL= MQTT_USERNAME= MQTT_PASSWORD= REST_USERNAME= -REST_PASSWORD= \ No newline at end of file +REST_PASSWORD= +REST_URL= +REST_PAGE= diff --git a/docker-compose.yml b/docker-compose.yml index de427aa..3547b33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -109,8 +109,10 @@ services: - "8080:8080" environment: - - VUE_APP_INFLUXDB_USER=$REST_USERNAME - - VUE_APP_INFLUXDB_PASSWORD=$REST_PASSWORD + - VUE_APP_REST_USER=$REST_USERNAME + - VUE_APP_REST_PASSWORD=$REST_PASSWORD + - VUE_APP_REST_URL=$REST_URL + - VUE_APP_REST_PAGE=$REST_PAGE labels: - "traefik.enable=true" diff --git a/docs/class-diagram-web-app.puml b/docs/class-diagram-web-app.puml index 7ffb7bb..a7e0224 100644 --- a/docs/class-diagram-web-app.puml +++ b/docs/class-diagram-web-app.puml @@ -1,66 +1,69 @@ @startuml web-app skinparam linetype ortho +skinparam nodesep 50 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 { +class ChartComponent { ' vue js component -' ask new measure + scatter } +class ControlPanel { +' vue js component + action-button + refresh-button + user-multiselect + room-multiselect + device-multiselect +} + +class Client { + +isconnected() + +getValues(user, room, device) + +getNewValue() + -ping() + -getAuthHeader() +} + +class TimeSeriesManager { +' get data, put data in graphs, ask new measure + +selected_user + +selected_room + +selected_device + +series + + +getTimeSeriesData() + +getNewValue() +} + class Serie { ' contains table of measure ' convert the table to graphs format -type - -values - -numberOfLastValus - -id - -place - -name + -data + -user + -room + -device - +getGraphFormat() - ' needs to limit the number of value - +addNewValue() -} - -class Plot { + +getLabel() + +getSerie() } -App *-r- SeriesManager + +App *-r- TimeSeriesManager App *-u- Client -SeriesManager *-r- TimeSeries -Plot -l-* TimeSeries -Serie -u-* TimeSeries -Serie *-- Button -SerieConverter .u.> SeriesManager +App *-d- ChartComponent +App *-d- ControlPanel +TimeSeriesManager *---r--- Serie + + + @enduml \ No newline at end of file diff --git a/docs/class-diagramm-web-app.svg b/docs/class-diagramm-web-app.svg new file mode 100644 index 0000000..b330efb --- /dev/null +++ b/docs/class-diagramm-web-app.svg @@ -0,0 +1 @@ +AppChartComponentscatterControlPanelaction-buttonrefresh-buttonuser-multiselectroom-multiselectdevice-multiselectClientisconnected()getValues(user, room, device)getNewValue()ping()getAuthHeader()TimeSeriesManagerselected_userselected_roomselected_deviceseriesgetTimeSeriesData()getNewValue()SerietypedatauserroomdevicegetLabel()getSerie() \ No newline at end of file diff --git a/web-app/.gitlab-ci.yml b/web-app/.gitlab-ci.yml index 5fcd239..2969ba0 100644 --- a/web-app/.gitlab-ci.yml +++ b/web-app/.gitlab-ci.yml @@ -2,6 +2,11 @@ variables: DOCKER_IMAGE: registry.forge.hefr.ch/team-raclette/project-softweng/web-app:latest NODE_IMAGE: cypress/included:cypress-14.4.1-node-22.16.0-chrome-137.0.7151.68-1-ff-139.0.1-edge-137.0.3296.62-1 + VUE_APP_REST_USER: $REST_USER + VUE_APP_REST_PASSWORD: $REST_PASSWORD + VUE_APP_REST_URL: $REST_URL + VUE_APP_REST_PAGE: $TEST_PAGE + default: image: $DOCKER_IMAGE @@ -35,9 +40,10 @@ web-app-unit-tests: web-app-e2e-tests: image: $NODE_IMAGE stage: web-app-tests - services: - - name: $DOCKER_IMAGE - alias: app + + # services: + # - name: $DOCKER_IMAGE + # alias: app script: - echo "Running e2e tests" - apt-get update @@ -45,6 +51,6 @@ web-app-e2e-tests: - cd ./web-app - npm install --include=dev - echo "Wait web-app app is running" - - for i in {1..6}; do curl -f http://app:8080 && break || sleep 5; done - - echo "Flask app is running" + - for i in {1..6}; do curl -f https://app.mse.kb28.ch/ && break || sleep 5; done + - echo "App is running" - npm run test:e2e diff --git a/web-app/README.md b/web-app/README.md index a519bfe..ebdac62 100644 --- a/web-app/README.md +++ b/web-app/README.md @@ -39,8 +39,10 @@ Setup the environment: linux: ```terminal -export VUE_APP_API_KEY=your_api_key -export VUE_APP_API_SECRET=your_api_secret +export VUE_APP_REST_USER= +export VUE_APP_REST_PASSWORD= +export VUE_APP_REST_URL= +export VUE_APP_REST_PAGE= ``` windows: ```terminal @@ -58,6 +60,11 @@ npm run serve npm run build ``` +## Documentation +### Class diagram + +

![Class Diagram](docs/class-diagramm-web-app.svg) + ### Links See [Configuration Reference](https://cli.vuejs.org/config/).\ See [Vue.js](https://vuejs.org/guide/introduction.html).\ diff --git a/web-app/cypress.config.ts b/web-app/cypress.config.ts index 68d0dc3..6263ae2 100644 --- a/web-app/cypress.config.ts +++ b/web-app/cypress.config.ts @@ -2,21 +2,12 @@ import { defineConfig } from "cypress"; export default defineConfig({ e2e: { - specPattern: "tests/e2e/*.spec.ts", - baseUrl: "http://localhost:8080", - supportFile: "tests/support/e2e.ts", - // Don't skip server checks - we want to ensure the app is running - // Wait up to 10 seconds for the server to be available - defaultCommandTimeout: 10000, - requestTimeout: 10000, - responseTimeout: 10000, - pageLoadTimeout: 30000, - }, - component: { - devServer: { - framework: "vue", - bundler: "webpack", + setupNodeEvents(on, config) { + // implement node event listeners here }, - specPattern: "src/**/*.cy.ts", + specPattern: [ + "cypress/e2e/*.cy.{js,jsx,ts,tsx}", + "cypress/unit/*.cy.{js,jsx,ts,tsx}", + ], }, }); diff --git a/web-app/cypress.unit.js b/web-app/cypress.unit.js deleted file mode 100644 index 0dca11f..0000000 --- a/web-app/cypress.unit.js +++ /dev/null @@ -1,40 +0,0 @@ -const { defineConfig } = require('cypress'); - -module.exports = defineConfig({ - // Shared settings for all test types - watchForFileChanges: false, - screenshotOnRunFailure: false, - video: false, - defaultCommandTimeout: 10000, - chromeWebSecurity: false, - retries: { - runMode: 2, - openMode: 0, - }, - - // E2E test configuration - e2e: { - specPattern: ['tests/e2e/*.spec.ts', 'tests/unit/*.spec.ts'], - baseUrl: null, // No baseUrl to prevent server checks in headless mode - supportFile: 'tests/support/e2e.ts', - experimentalRunAllSpecs: true, - testIsolation: false, // Allow shared context for unit tests - setupNodeEvents(on, config) { - // Disable baseUrl verification for headless testing - on('before:browser:launch', (browser, launchOptions) => { - return launchOptions; - }); - return config; - }, - }, - - // Component test configuration (for Vue components) - component: { - devServer: { - framework: 'vue', - bundler: 'webpack', - }, - specPattern: 'tests/unit/*.spec.ts', // Component tests are in unit directory - supportFile: 'tests/support/e2e.ts', - }, -}); diff --git a/web-app/cypress/e2e/fetch_process.cy.ts b/web-app/cypress/e2e/fetch_process.cy.ts new file mode 100644 index 0000000..0e3cfc3 --- /dev/null +++ b/web-app/cypress/e2e/fetch_process.cy.ts @@ -0,0 +1,33 @@ +/// + +describe("Test fetch measurments button in the main page", () => { + beforeEach(() => { + cy.visit("https://app.mse.kb28.ch"); + }); + + it("Fetch timeseries with valid user-room-device", () => { + cy.get('[data-cy="fetch-measurements-button"]').should("be.visible"); + // keep default mulitselector value for the test + cy.get('[data-cy="fetch-measurements-button"]').click(); + + cy.get('[data-cy="main-chart"]').should("be.visible"); + cy.get('[data-cy="no-data-display"]').should("not.exist"); + }); + + it("Fetch timeseries with invalid user-room-device", () => { + //change the defualt user to "Sylvan" + cy.get('[data-cy="user-mulitselect"]').should("be.visible").click(); + cy.get( + '[data-cy="user-mulitselect"] .multiselect__content-wrapper .multiselect__element' + ) + .contains("Sylvan") + .click(); + + cy.get('[data-cy="fetch-measurements-button"]').should("be.visible"); + // keep default mulitselector value for the test + cy.get('[data-cy="fetch-measurements-button"]').click(); + + cy.get('[data-cy="main-chart"]').should("not.exist"); + cy.get('[data-cy="no-data-display"]').should("be.visible"); + }); +}); diff --git a/web-app/cypress/support/commands.ts b/web-app/cypress/support/commands.ts new file mode 100644 index 0000000..698b01a --- /dev/null +++ b/web-app/cypress/support/commands.ts @@ -0,0 +1,37 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// +// declare global { +// namespace Cypress { +// interface Chainable { +// login(email: string, password: string): Chainable +// drag(subject: string, options?: Partial): Chainable +// dismiss(subject: string, options?: Partial): Chainable +// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable +// } +// } +// } \ No newline at end of file diff --git a/web-app/cypress/support/e2e.ts b/web-app/cypress/support/e2e.ts new file mode 100644 index 0000000..e4e246e --- /dev/null +++ b/web-app/cypress/support/e2e.ts @@ -0,0 +1,17 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' \ No newline at end of file diff --git a/web-app/tests/unit/Serie.spec.ts b/web-app/cypress/unit/serie.cy.ts similarity index 89% rename from web-app/tests/unit/Serie.spec.ts rename to web-app/cypress/unit/serie.cy.ts index d1c228b..a52e072 100644 --- a/web-app/tests/unit/Serie.spec.ts +++ b/web-app/cypress/unit/serie.cy.ts @@ -8,82 +8,85 @@ describe("Serie Component Tests", () => { // Mock data for tests const mockDataTemperature = [ { time: 1625097600000, value: new Date(22.5) }, - { time: 1625184000000, value: new Date(23.8) } + { time: 1625184000000, value: new Date(23.8) }, ]; - + const mockDataHumidity = [ { time: 1625097600000, value: new Date(45) }, - { time: 1625184000000, value: new Date(48) } + { time: 1625184000000, value: new Date(48) }, ]; it("should initialize with correct properties", () => { // Create a new Serie instance const serie = new Serie( - TEMPERATURE, + TEMPERATURE, mockDataTemperature, "user1", "bedroom", "sensor1" ); - + // Verify that it has the correct type expect(serie).to.be.instanceOf(Serie); }); it("should return the correct label for temperature", () => { const serie = new Serie( - TEMPERATURE, + TEMPERATURE, mockDataTemperature, "user1", "bedroom", "sensor1" ); - + expect(serie.getLabel()).to.equal("Temperature [°C]"); }); it("should return the correct label for humidity", () => { const serie = new Serie( - HUMIDITY, + HUMIDITY, mockDataHumidity, "user1", "bedroom", "sensor1" ); - + expect(serie.getLabel()).to.equal("Humidity [%]"); }); it("should return 'Unknown' label for unknown type", () => { const serie = new Serie( - "UNKNOWN_TYPE", + "UNKNOWN_TYPE", mockDataTemperature, "user1", "bedroom", "sensor1" ); - + expect(serie.getLabel()).to.equal("Unknown"); }); it("should format temperature data correctly with getSerie()", () => { const serie = new Serie( - TEMPERATURE, - mockDataTemperature.map(item => ({ time: item.time, value: new Date(item.value) })), + TEMPERATURE, + mockDataTemperature.map((item) => ({ + time: item.time, + value: new Date(item.value), + })), "user1", "bedroom", "sensor1" ); - + const result = serie.getSerie(); - + // Verify the properties of the formatted data expect(result).to.have.property("label", "Temperature [°C]"); expect(result).to.have.property("borderColor", Colors.BLUE); expect(result).to.have.property("backgroundColor", Colors.BLUE); expect(result).to.have.property("borderWidth", 1); expect(result).to.have.property("yAxisID", TEMPERATURE); - + // Verify the data points expect(result.data).to.have.length(mockDataTemperature.length); expect(result.data[0]).to.have.property("x", mockDataTemperature[0].time); @@ -92,15 +95,15 @@ describe("Serie Component Tests", () => { it("should format humidity data correctly with getSerie()", () => { const serie = new Serie( - HUMIDITY, + HUMIDITY, mockDataHumidity, "user1", "bedroom", "sensor1" ); - + const result = serie.getSerie(); - + // Verify the properties of the formatted data expect(result).to.have.property("label", "Humidity [%]"); expect(result).to.have.property("borderColor", Colors.GREEN); @@ -111,19 +114,19 @@ describe("Serie Component Tests", () => { it("should handle unknown types appropriately in getSerie()", () => { const serie = new Serie( - "UNKNOWN_TYPE", + "UNKNOWN_TYPE", mockDataTemperature, "user1", "bedroom", "sensor1" ); - + const result = serie.getSerie(); - + // Verify the properties for unknown type expect(result).to.have.property("label", "Unknown"); expect(result).to.have.property("borderColor", Colors.RED); expect(result).to.have.property("backgroundColor", Colors.RED); expect(result).to.have.property("yAxisID", TEMPERATURE); // Defaults to TEMPERATURE }); -}); \ No newline at end of file +}); diff --git a/web-app/package.json b/web-app/package.json index 0a4bc86..27e7aa6 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -7,8 +7,8 @@ "build": "vue-cli-service build", "cypress:open": "cypress open", "cypress:run": "cypress run", - "test:unit": "cypress run --config-file cypress.unit.js --spec 'tests/unit/*.spec.ts'", - "test:e2e": "cypress run --spec 'tests/e2e/*.spec.ts'", + "test:e2e": "cypress run --spec 'cypress/e2e/*.cy.ts'", + "test:unit": "cypress run --spec 'cypress/unit/*.cy.ts'", "test": "npm run test:unit && npm run test:e2e" }, "dependencies": { diff --git a/web-app/src/Measures/TimeSeriesManager.ts b/web-app/src/Measures/TimeSeriesManager.ts index 525b1c4..53a1f37 100644 --- a/web-app/src/Measures/TimeSeriesManager.ts +++ b/web-app/src/Measures/TimeSeriesManager.ts @@ -1,7 +1,6 @@ -import { Prop } from "vue"; import { Serie } from "./Serie"; import { HttpClient } from "../Services/HttpClient"; -import { TEMPERATURE, HUMIDITY, TYPE, VALUE } from "../const"; +import { TEMPERATURE, HUMIDITY, TYPE } from "../const"; import { ref } from "vue"; export class TimeSeriesManager { @@ -57,8 +56,6 @@ export class TimeSeriesManager { }, ]; - error = ref(false); - loading = ref(""); series = ref([]); @@ -125,12 +122,11 @@ export class TimeSeriesManager { this.selected_room.room, this.selected_device.device ); - this.series.value = [temperatureSerie, humiditySerie]; }) .catch((error) => { console.error("Error fetching time series data:", error); - throw error; // Re-throw to allow calling code to handle it + this.series.value = []; }); } diff --git a/web-app/src/Services/HttpClient.ts b/web-app/src/Services/HttpClient.ts index 10fdde9..c019474 100644 --- a/web-app/src/Services/HttpClient.ts +++ b/web-app/src/Services/HttpClient.ts @@ -1,5 +1,5 @@ import axios, { AxiosResponse } from "axios"; -import { BASE, PING, RACLETTE, PONG } from "../const"; +import { BASE, PING, PAGE, PONG } from "../const"; export class HttpClient { private _url: string; @@ -45,7 +45,7 @@ export class HttpClient { room: string, device: string ): Promise> { - const response = await axios.get(`${BASE}${this._url}/${RACLETTE}`, { + const response = await axios.get(`${BASE}${this._url}/${PAGE}`, { headers: this.getAuthHeader(), params: { user: user, @@ -62,7 +62,7 @@ export class HttpClient { device: string ): Promise> { const response = await axios.post( - `${BASE}${this._url}/${RACLETTE}`, + `${BASE}${this._url}/${PAGE}`, { command: "MEASURE_NEW", }, diff --git a/web-app/src/components/ChartComponent.vue b/web-app/src/components/ChartComponent.vue index ff4ac4a..171bf44 100644 --- a/web-app/src/components/ChartComponent.vue +++ b/web-app/src/components/ChartComponent.vue @@ -1,16 +1,10 @@ @@ -133,9 +127,17 @@ export default defineComponent({ }, }; + const isTimeSeries = computed(() => { + return ( + props.manager.series.value && + (props.manager.series.value as Serie[]).length > 0 + ); + }); + return { chartOptions, chartData, + isTimeSeries, }; }, }); diff --git a/web-app/src/components/ControlPanel.vue b/web-app/src/components/ControlPanel.vue index f12fe36..f21f4a5 100644 --- a/web-app/src/components/ControlPanel.vue +++ b/web-app/src/components/ControlPanel.vue @@ -1,10 +1,14 @@