diff --git a/.github/workflows/basic_test.yml b/.github/workflows/basic_test.yml index 0de401a..eb8a374 100644 --- a/.github/workflows/basic_test.yml +++ b/.github/workflows/basic_test.yml @@ -17,7 +17,7 @@ jobs: run: docker-compose -f test/docker-compose.yml up -d - name: Wait for service to start - run: sleep 20 + run: sleep 30 - name: Check status code run: | @@ -27,4 +27,10 @@ jobs: echo "Request failed with status code:" echo ${status_code} exit 1 - fi \ No newline at end of file + fi + + - name: failed tests 🚩 + if: ${{ failure() }} + run: | + echo "check docker logs" + docker-compose -f test/docker-compose.yml logs \ No newline at end of file diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml new file mode 100644 index 0000000..23e07a8 --- /dev/null +++ b/.github/workflows/ghcr.yml @@ -0,0 +1,60 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# GitHub recommends pinning actions to a commit SHA. +# To get a newer version, you will need to update the SHA. +# You can also reference a tag or branch, but the action may change without warning. + +# +name: Create and publish a Docker image on gchr.io + +#push on main and on published +on: + push: + branches: + - main + release: + types: [published] + +# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. +jobs: + build-and-push-image: + runs-on: ubuntu-latest + # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. + permissions: + contents: read + packages: write + # + steps: + - name: Checkout repository + uses: actions/checkout@v3 + # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. + # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. + # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + - name: Build and push Docker image + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: webapp + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/webapp/Dockerfile b/webapp/Dockerfile index 17a2d5a..1a7c553 100644 --- a/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -1,5 +1,8 @@ FROM node:lts-alpine +# current version +LABEL version="1.0b5" + # Set the working directory inside the container WORKDIR /wis2box-webapp diff --git a/webapp/config/HomeContent.html b/webapp/config/HomeContent.html new file mode 100644 index 0000000..08e4d6d --- /dev/null +++ b/webapp/config/HomeContent.html @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/webapp/src/components/CsvToBUFRForm.vue b/webapp/src/components/CsvToBUFRForm.vue index f205263..e4827e2 100644 --- a/webapp/src/components/CsvToBUFRForm.vue +++ b/webapp/src/components/CsvToBUFRForm.vue @@ -230,6 +230,7 @@ import { VStepper, VStepperHeader, VStepperItem, VStepperWindow, VStepperWindowItem, VStepperActions} from 'vuetify/lib/labs/VStepper/index.mjs'; import InspectBufrButton from '@/components/InspectBufrButton.vue'; import DownloadButton from '@/components/DownloadButton.vue'; + import TopicHierarchySelector from '@/components/TopicHierarchySelector.vue'; import * as d3 from 'd3'; export default defineComponent({ @@ -240,6 +241,7 @@ VCardTitle, VIcon, VStepper, VStepperHeader, VStepperItem, VStepperWindow, VStepperWindowItem, VStepperActions, VDialog, VCardSubtitle, InspectBufrButton, DownloadButton, TopicHierarchySelector + }, setup() { // reactive variables diff --git a/webapp/src/components/DownloadButton.vue b/webapp/src/components/DownloadButton.vue index 90e9710..d4c0bb6 100644 --- a/webapp/src/components/DownloadButton.vue +++ b/webapp/src/components/DownloadButton.vue @@ -9,29 +9,61 @@ import { VBtn } from 'vuetify/lib/components/index.mjs'; export default defineComponent({ name: 'DownloadButton', props: { - fileUrl: { + fileName: { type: String, required: true, }, + fileUrl: { + type: String, + required: false, + default: '', + }, + data: { + type: String, + required: false, + default: '', + }, }, components: { VBtn }, setup(props) { - // Extract the file name from the URL - const fileName = props.fileUrl.split('/').pop(); - // function to download file const downloadFile = () => { // Create a temporary anchor element to initiate the download const link = document.createElement('a'); - link.href = props.fileUrl; - link.target = '_blank'; - // Programmatically trigger the click event on the link to start the download - link.click(); + if( props.fileUrl != '' ) { + //console.log("Downloading file from URL: " + props.fileUrl) + link.href = props.fileUrl; + link.target = '_blank'; + // Programmatically trigger the click event on the link to start the download + link.click(); + // Clean up + URL.revokeObjectURL(link.href); + } + else if( props.data != '' ) { + //console.log("Downloading file from data: " + props.data) + // Decode the base64 encoded data + const decodedData = atob(props.data); + // Convert the decoded data to a Uint8Array + const uint8Array = new Uint8Array(decodedData.length); + for (let i = 0; i < decodedData.length; ++i) { + uint8Array[i] = decodedData.charCodeAt(i); + } + // Create a Blob with the Uint8Array data + const blob = new Blob([uint8Array], { type: 'application/octet-stream' }); + link.href = URL.createObjectURL(blob); + link.download = props.fileName; + // Programmatically trigger the click event on the link to start the download + link.click(); + // Clean up + URL.revokeObjectURL(link.href); + } + else { + console.log("No fileURL or data provided"); + } }; return { - fileName, downloadFile }; }, diff --git a/webapp/src/components/HomePage.vue b/webapp/src/components/HomePage.vue new file mode 100644 index 0000000..6a1ae23 --- /dev/null +++ b/webapp/src/components/HomePage.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/webapp/src/components/InspectBufrButton.vue b/webapp/src/components/InspectBufrButton.vue index 03e076e..ba7dad1 100644 --- a/webapp/src/components/InspectBufrButton.vue +++ b/webapp/src/components/InspectBufrButton.vue @@ -6,6 +6,10 @@ {{ fileName }} +
+ +

No items found in bufr

+
{{ key }}: {{ value }} @@ -26,10 +30,20 @@ import { VCard, VCardTitle, VCardText, VCardItem, VForm, VTextarea, VBtn, VSelec export default defineComponent({ name: 'InspectBufrButton', props: { - fileUrl: { + fileName: { type: String, required: true, }, + fileUrl: { + type: String, + required: false, + default: '', + }, + data: { + type: String, + required: false, + default: '', + }, }, components: { VCard, @@ -42,8 +56,6 @@ export default defineComponent({ VSelect, }, setup(props) { - // Extract the file name from the URL - const fileName = props.fileUrl.split('/').pop(); const itemsInBufr = ref([]); const dialog = ref(false); // function to create new object and to add to store @@ -91,12 +103,21 @@ export default defineComponent({ const callInspect = async () => { // set items_from_bufr back to empty array itemsInBufr.value = []; - var payload = { + let payload; + if (props.fileUrl !== '') { + payload = { + inputs: { + data_url: props.fileUrl + } + }; + } else if (props.data !== '') { + payload = { inputs: { - data_url: props.fileUrl + data: props.data } + }; }; - const inspectUrl = `${import.meta.env.VITE_API_URL}/processes/wis2box-bufr2geojson/execution` + const inspectUrl = `${import.meta.env.VITE_API_URL}/processes/bufr2geojson/execution` const response = await fetch(inspectUrl, { method: 'POST', headers: { @@ -110,7 +131,7 @@ export default defineComponent({ console.error('HTTP error', response.status); } else { const data = await response.json(); - console.log(data); + //console.log(data); if (data.items) { // Use Array.map to create a new array of the items in the bufr file itemsInBufr.value = data.items.map(item => { @@ -118,15 +139,15 @@ export default defineComponent({ return item.properties; } }); - console.log(itemsInBufr.value); + //console.log(itemsInBufr.value); } } }; return { - fileName, itemsInBufr, inspectFile, - dialog + dialog, + fileName: props.fileName }; }, }); diff --git a/webapp/src/components/MonitoringPage.vue b/webapp/src/components/MonitoringPage.vue index c39edf8..6da8168 100644 --- a/webapp/src/components/MonitoringPage.vue +++ b/webapp/src/components/MonitoringPage.vue @@ -1,15 +1,15 @@ diff --git a/webapp/src/components/NotificationDashboard.vue b/webapp/src/components/NotificationDashboard.vue index 578c6d1..6cdb04e 100644 --- a/webapp/src/components/NotificationDashboard.vue +++ b/webapp/src/components/NotificationDashboard.vue @@ -1,119 +1,230 @@ @@ -137,14 +248,21 @@ export default defineComponent({ // is updated when the user selects another dataset in MonitoringPage.vue topicHierarchy(newVal, oldVal) { if (newVal !== oldVal) { - // Run all important methods - this.getNotifications(); - this.getTimestampCounts(); - this.getWsiCounts(); - this.getSummary(); - this.initChartData(); + console.log("Topic hierarchy changed to:", newVal); + this.update_messages(); } - } + }, + messages: { + immediate: false, // To not trigger the watcher immediately on component mount + deep: false, // To not deep watch changes within the array + handler(newMessages) { + console.log("Messages changed"); + // Run the methods dependent on the messages + console.log("Messages: ", newMessages); + this.updateSummary(); + this.updateChartData(); + }, + }, }, components: { VCard, @@ -157,15 +275,57 @@ export default defineComponent({ data() { return { // Messages from API call - messages: [], + messages: [], // Array of messages sorted by pubtime // Example message when Romania synoptic dataset selected by user - testMessageSynoptic: [ + testMessageRomania: [ + { + "id": "8855221f-2112-43fa-b2da-1552e8aa9a2d", + "properties": { + "data_id": "wis2/rou/rnimh/data/core/weather/surface-based-observations/synop/WIGOS_0-20000-0-15020_20220331T000000", + "datetime": "2022-03-31T00:00:00Z", + "pubtime": "2023-09-04T03:58:20Z", + "wigos_station_identifier": "0-20000-0-15020", + "id": "8855221f-2112-43fa-b2da-1552e8aa9a2d" + }, + "links": [ + { + "rel": "canonical", + "type": "application/x-bufr", + "href": "http://3.73.37.35/data/2022-03-31/wis/rou/rnimh/data/core/weather/surface-based-observations/synop/WIGOS_0-20000-0-15020_20220331T000000.bufr4", + }, + { + "rel": "via", + "type": "text/html", + "href": "https://oscar.wmo.int/surface/#/search/station/stationReportDetails/0-20000-0-15015" + }] + }, + { + "id": "8855221f-2112-43fa-b2da-1552e8aa9a2d", + "properties": { + "data_id": "wis2/rou/rnimh/data/core/weather/surface-based-observations/synop/WIGOS_0-20000-0-15020_20220331T000000", + "datetime": "2022-03-31T00:00:00Z", + "pubtime": "2023-09-04T03:58:25Z", + "wigos_station_identifier": "0-20000-0-15020", + "id": "8855221f-2112-43fa-b2da-1552e8aa9a2d" + }, + "links": [ + { + "rel": "canonical", + "type": "application/x-bufr", + "href": "http://3.73.37.35/data/2022-03-31/wis/rou/rnimh/data/core/weather/surface-based-observations/synop/WIGOS_0-20000-0-15020_20220331T000000.bufr4", + }, + { + "rel": "via", + "type": "text/html", + "href": "https://oscar.wmo.int/surface/#/search/station/stationReportDetails/0-20000-0-15015" + }] + }, { "id": "8855221f-2112-43fa-b2da-1552e8aa9a2d", "properties": { "data_id": "wis2/rou/rnimh/data/core/weather/surface-based-observations/synop/WIGOS_0-20000-0-15020_20220331T000000", "datetime": "2022-03-31T00:00:00Z", - "pubtime": "2023-08-24T13:58:20Z", + "pubtime": "2023-09-04T11:58:20Z", "wigos_station_identifier": "0-20000-0-15020", "id": "8855221f-2112-43fa-b2da-1552e8aa9a2d" }, @@ -183,7 +343,7 @@ export default defineComponent({ } ], // Example message when Malawi surface dataset selected by user - testMessageSurface: [ + testMessageMalawi: [ { "id": "af14d8c4-5f63-45af-8171-7730ec9932ba", "properties": { @@ -206,17 +366,6 @@ export default defineComponent({ }] } ], - // Object containing the publish time and associated file URLs of - // each notification from the last 24 hours - notificationData: { - "publishTimes": [], - "fileUrls": [] - }, - // Count for how many files were published at a given time, rounded - // to the nearest minute - timestampCounts: {}, - // Count for how files were published from a given WSI - wsiCounts: {}, // Object for notification summary statistics summaryStats: { "totalFilesLastHour": 0, @@ -231,28 +380,13 @@ export default defineComponent({ // Initiate ApexCharts series to be filled with data later chartSeries: [ { - name: 'BUFR files published', + name: 'WIS2 notifications published', data: [] } - ] - } - }, - computed: { - // Get current time - now() { - return new Date(); - }, - // Get time 1 hour ago from now - oneHourAgo() { - return new Date(this.now.getTime() - 1 * 60 * this.mins); - }, - // Get time 24 hours ago from now - oneDayAgo() { - return new Date(this.now.getTime() - 24 * 60 * this.mins); - }, - // Options for the ApexChart bar graph - chartOptions() { - return { + ], + // ApexCharts options + // Options for the ApexChart bar graph + chartOptions: { chart: { type: 'bar', id: 'realtime', @@ -286,16 +420,16 @@ export default defineComponent({ }, tooltip: { x: { - format: 'dd MMM HH:mm' + format: 'dd MMM HH:mm', } }, colors: ['#00BD9D'], // Colour of bars xaxis: { type: 'datetime', - // Earliest displayed time 1 hour from current - min: this.oneHourAgo.getTime(), - // Latest displayed time is current time - max: this.now.getTime() + categories: this.getLast24Hours(), + labels: { + format: "dd MMM HH:mm", // Format the x-axis labels as desired + } }, yaxis: { min: 0, @@ -309,27 +443,76 @@ export default defineComponent({ show: false } } - } + }, + // Search parameter for published files + fileSearch: null } }, methods: { + getLast24Hours() { + const now = new Date(); + const past24Hours = new Date(now - 24 * 60 * 60 * 1000); // Subtract 24 hours in milliseconds + const timeRange = []; + + for (let time = past24Hours; time <= now; time += 60 * 60 * 1000) { // Generate data points every hour + timeRange.push(time); + } + console.log("Time range: ", timeRange); + return timeRange; + }, + now() { + return new Date(); + }, + // Get time 1 hour ago from now + oneHourAgo() { + return new Date(this.now().getTime() - 1 * 60 * this.mins); + }, + // Get time 24 hours ago from now + oneDayAgo() { + return new Date(this.now().getTime() - 24 * 60 * this.mins); + }, + // Method to get the messages from the features array + getMessagesFromFeatures(features) { + const selectedFields = features.map(item => ({ + pubtime: new Date(item.properties.pubtime + "Z"), + canonical_url: this.getCanonicalUrl(item.links), + filename: this.getFileName(this.getCanonicalUrl(item.links)) + // Add more fields as needed + })); + // sort by pubtime descending + return selectedFields.sort((a, b) => b.pubtime - a.pubtime); + }, // Builds a topic hierarchy dependent url and fetches the notifications - async apiCall() { - // If in TEST_MODE or API URL is not defined, just return. + async update_messages() { + console.log("Dataset selected: ", this.topicHierarchy); + // Check if TEST_MODE is set in .env file or if VITE_API_URL is not set if (import.meta.env.VITE_TEST_MODE === "true" || import.meta.env.VITE_API_URL == undefined) { - return; + console.log("TEST_MODE is enabled"); + // Use example data selected by user + let test_features = []; + if (this.topicHierarchy == "rou/rnimh/data/core/weather/surface-based-observations/synop") { + test_features = this.testMessageRomania; + } + else if (this.topicHierarchy == "mwi/mwi_met_centre/data/core/weather/surface-based-observations/synop") { + test_features = this.testMessageMalawi; + } + this.messages = this.getMessagesFromFeatures(test_features); } - + else { + // Use API to get data + await this.apiCall(); + } + }, + async apiCall() { const apiUrl = `${import.meta.env.VITE_API_URL}/collections/messages/items`; console.log("Fetching notifications from:", apiUrl); - try { const params = new URLSearchParams({ f: 'json', // Specify the response format as JSON data_id: `${this.topicHierarchy}%`, // Filter by data_id that starts with the provided topic hierarchy sortBy: '-datetime', // Sort by time in descending order - limit: 9999, // Limit the results to the last 9999 features - datetime: `${this.oneDayAgo.toISOString()}/${this.now.toISOString()}`, // Filter to last 24 hours + limit: 500, // Limit the results to the last 500 features + datetime: `${this.oneDayAgo().toISOString()}/${this.now().toISOString()}`, // Filter to last 24 hours }); // Make the HTTP GET request console.log("API request:", `${apiUrl}?${params}`) @@ -340,8 +523,7 @@ export default defineComponent({ else { const data = await response.json(); if (data.features) { - this.messages = data.features; - console.log("Messages:", this.messages); + this.messages = this.getMessagesFromFeatures(data.features); } else { console.error("API response does not contain features:", data); @@ -354,58 +536,24 @@ export default defineComponent({ }, // Method to get summary statistics of total published files in the // past hour and past 24 hours - async getSummary() { + updateSummary() { + console.log("Update summary statistics"); // Get the number of publish times from the last hour - const timesWithinHour = this.notificationData.publishTimes.filter(time => { - return time >= this.oneHourAgo; + const timesWithinHour = this.messages.filter(message => { + const publishTime = message.pubtime; + return publishTime >= this.oneHourAgo(); }).length; - // Get the number of publish times from the last 24 hours // (which is all of the publish times by the way we call the API) - const timesWithinDay = this.notificationData.publishTimes.length; - + const timesWithinDay = this.messages.length; // Update the summary statistics object this.summaryStats.totalFilesLastHour = timesWithinHour; this.summaryStats.totalFilesLastDay = timesWithinDay; }, - // Loads notification data of publish times and file urls - async getNotifications() { - // Check if TEST_MODE is set in .env file or if VITE_API_URL is not set - if (import.meta.env.VITE_TEST_MODE === "true" || import.meta.env.VITE_API_URL == undefined) { - console.log("TEST_MODE is enabled"); - console.log("Dataset selected: ", this.topicHierarchy); - // Use example data selected by user - if (this.topicHierarchy == "rou/rnimh/data/core/weather/surface-based-observations/synop") { - this.messages = this.testMessageSynoptic; - } - else if (this.topicHierarchy == "mwi/mwi_met_centre/data/core/weather/surface-based-observations/synop") { - this.messages = this.testMessageSurface; - } - } - else { - await this.apiCall(); - } - - this.notificationData = { - // Get the publish times of the notifications as an array - publishTimes: this.messages.map(item => - new Date(item.properties.pubtime)), - // Get the file urls of the notifications as an array - fileUrls: this.messages.map(item => { - const canonicalLink = item.links.find(link => link.rel === "canonical"); - // The file url is the href value associated with the canonical relation - return canonicalLink ? canonicalLink.href : null; - }) - } - - // Get summary statistics of notification data - await this.getSummary(); - - console.log("Publish times and URLs: ", this.notificationData); - }, + // Round a datetime to the nearest minute, used by updateChartData() roundToNearestMinute(time) { // Get minutes and seconds of the datetime - let minutes = time.getMinutes(); + let minutes = time.getUTCMinutes(); let seconds = time.getSeconds(); if (seconds >= 30) { @@ -418,123 +566,67 @@ export default defineComponent({ return time; }, - // Builds the timestamp count array used in the ApexCharts bar graph - async getTimestampCounts() { - // Reset the timestamp counts - this.timestampCounts = {}; - - // For each publish time, round to the nearest minute and update - // the count - this.notificationData["publishTimes"].forEach(time => { - - const roundedTime = this.roundToNearestMinute(time); - - // Update timestampCount object with this rounded publish time - if (this.timestampCounts[roundedTime]) { - // If the key already exists, add to the count - this.timestampCounts[roundedTime]++; - } - else { - // Otherwise begin the count at 1 - this.timestampCounts[roundedTime] = 1; + // updateChartData() counts the number of messages per minute and uses this + updateChartData() { + console.log("update chart data"); + // count messages per minutes and uses this create the chart data + const timestampCounts = {}; + // add 24hour ago, 0 to timestampCounts + timestampCounts[this.oneDayAgo()] = 0; + // add now, 0 to timestampCounts + timestampCounts[this.now()] = 0; + this.messages.forEach(message => { + const publishTime = new Date(message.pubtime); + const roundedTime = this.roundToNearestMinute(publishTime); + if (timestampCounts[roundedTime]) { + timestampCounts[roundedTime]++; + } else { + timestampCounts[roundedTime] = 1; } }); - console.log("Timestamp counts: ", this.timestampCounts) - }, - // Builds the wsiCounts object - async getWsiCounts() { - - // Reset the wsi counts - this.wsiCounts = {}; - - // Check if TEST_MODE is set in .env file or if VITE_API_URL is not set - if (import.meta.env.VITE_TEST_MODE === "true" || import.meta.env.VITE_API_URL == undefined) { - // Use example data selected by user - if (this.topicHierarchy == "rou/rnimh/data/core/weather/surface-based-observations/synop") { - this.messages = this.testMessageSynoptic; - } - else if (this.topicHierarchy == "mwi/mwi_met_centre/data/core/weather/surface-based-observations/synop") { - this.messages = this.testMessageSurface; - } - } - else { - await this.apiCall(); - } - // Group the messages based on 'wigos_station_identifier' add canonical_url property to each item - this.messages.forEach((item) => { - const canonicalLink = item.links.find(link => link.rel === "canonical"); - if (canonicalLink) { - item.canonical_url = canonicalLink.href; - } - const identifier = item.properties.wigos_station_identifier; - if (this.wsiCounts[identifier]) { - this.wsiCounts[identifier]++; - } - else { - this.wsiCounts[identifier] = 1 - } - }) + // Convert the timestampCounts object to an array of arrays + const chartData = Object.keys(timestampCounts).map(key => { + return [new Date(key).getTime(), timestampCounts[key]]; + }); - console.log("WSI counts: ", this.wsiCounts); - }, - // Initialise the data for ApexCharts series bar graph based on - // allNotifications - initChartData() { - // Creates a nested array structure of form [[timestamp1, count1],...] - const chartData = Object.entries(this.timestampCounts); + // sort chartData by time + chartData.sort((a, b) => a[0] - b[0]); + console.log("Chart data: ", chartData); - // Update the chartSeries data with the above - this.chartSeries[0].data = chartData; - }, - // Enables zoom functionality on bar graph - updateData: function (timeline) { - this.selectedZoom = timeline; - // Get current time const now = new Date().getTime(); + const twentyFourHoursAgo = now - (24 * 60 * this.mins); + this.chartOptions.xaxis.min = twentyFourHoursAgo; + this.chartOptions.xaxis.max = now; - // Depending on the button pressed, zoom the x-axis of the bar graph accordingly - switch (timeline) { - case 'one_hour': - this.$refs.chart.zoomX(now - (60 * this.mins), now); - break; - case 'three_hours': - this.$refs.chart.zoomX(now - (3 * 60 * this.mins), now); - break; - case 'six_hours': - this.$refs.chart.zoomX(now - (6 * 60 * this.mins), now); - break; - case 'twenty_four_hours': - this.$refs.chart.zoomX(now - (24 * 60 * this.mins), now); - break; - default: - } + this.chartSeries[0].data = chartData; }, - // Shows the HH:mm timestamp for the newest notifications + // Shows the time in a human readable format formatTime(timestamp) { - const hours = String(timestamp.getHours()).padStart(2, '0'); - const minutes = String(timestamp.getMinutes()).padStart(2, '0'); - return `${hours}:${minutes}`; + const year = timestamp.getUTCFullYear(); + const month = String(timestamp.getUTCMonth() + 1).padStart(2, '0'); + const day = String(timestamp.getUTCDate()).padStart(2, '0'); + const hours = String(timestamp.getUTCHours()).padStart(2, '0'); + const minutes = String(timestamp.getUTCMinutes()).padStart(2, '0'); + return `${year}/${month}/${day} ${hours}:${minutes} UTC`; }, // Gets the filename from the canonical href getFileName(url) { const urlParts = url.split('/'); return urlParts[urlParts.length - 1]; }, + getCanonicalUrl(links) { + const canonicalLink = links.find(link => link.rel === "canonical"); + if (canonicalLink) { + return canonicalLink.href; + } + return ''; + } }, mounted() { - this.apiCall(); - // Get notification data - this.getNotifications(); - // Get timestamp count data - this.getTimestampCounts(); - // Get WSI file count data - this.getWsiCounts(); - // Get summary statistics - this.getSummary(); - // Fill ApexCharts with latest data - this.initChartData(); + console.log("Mounted NotificationDashboard"); + this.update_messages(); } }); diff --git a/webapp/src/components/SynopForm.vue b/webapp/src/components/SynopForm.vue index af22b50..ab8ea2f 100644 --- a/webapp/src/components/SynopForm.vue +++ b/webapp/src/components/SynopForm.vue @@ -3,7 +3,14 @@ - Submit FM 12–XIV Ext. SYNOP Bulletin + + + +
+ Submit FM 12–XIV Ext. SYNOP Bulletin +
@@ -16,34 +23,48 @@

Month and year of the data

- + -

AAXX must be present for a valid SYNOP message +

AAXX must be present for a valid SYNOP + message

Delimiter (=) must be present for a valid SYNOP message

Raw FM 12 bulletin

- + - - + + + + + + +
+ +
- Submit - +
@@ -116,7 +137,7 @@ - - Output BUFR files: {{ result.files.length }} + Output BUFR files: {{ result.data_items.length }} - -
-
- {{ getFileName(file_url) }} -
+ + + - + +
+
+
+ {{ data_item.filename }} +
+
+ + +
+
+ + +
+
+
+
@@ -163,24 +207,6 @@ import DownloadButton from '@/components/DownloadButton.vue'; export default defineComponent({ name: 'RoleForm', - props: { - broker: { - type: String, - default: '' - }, - channel: { - type: String, - default: '' - }, - api: { - type: String, - default: 'api.opencdms.org' - }, - path: { - type: String, - default: '/processes/wis2box-synop-process/execution' - } - }, data() { // Default data values before reactivity return { @@ -193,9 +219,10 @@ export default defineComponent({ bulletin: "", // FM 12 data aaxxPresent: true, // Boolean to check if AAXX is in bulletin equalsPresent: true, // Boolean to check if = delimiter is in bulletin - hierarchyList: ["test1", "test2", "test3"], // List of topic hierarchies + topicList: ["test1", "test2", "test3"], // List of topic hierarchies // before they are obtained from discovery metadata - hierarchy: "", // Topic hierarchy selected by user + topic: "", // Topic hierarchy selected by user + token: "", // Execution token to be entered by user notificationsOnPending: true, // Realtime variable for if user has // selected notifications or not notificationsOn: true, // Variable that updates to the pending variable @@ -234,17 +261,15 @@ export default defineComponent({ this.equalsPresent = this.bulletin.includes('='); }, // Allows us to get the current topic hierarchies available - async fetchHierarchy() { + async fetchTopics() { const apiUrl = `${import.meta.env.VITE_API_URL}/collections/discovery-metadata/items?f=json`; - // check if TEST=True is set in .env file - console.log(import.meta.env); // check if TEST_MODE is set in .env file or if VITE_API_URL is not set if (import.meta.env.VITE_TEST_MODE === "true" || import.meta.env.VITE_API_URL == undefined) { console.log("TEST_MODE is enabled"); - this.hierachyList = ["test1", "test2", "test3"]; + this.topicList = ["test1", "test2", "test3"]; } else { - console.log("Fetching topic hierarchy from:", apiUrl); + //console.log("Fetching topic hierarchy from:", apiUrl); try { const response = await fetch(apiUrl); if (!response.ok) { @@ -252,14 +277,15 @@ export default defineComponent({ } else { const data = await response.json(); + // If the features object is in the data if (data.features) { // Use Array.map to create a new array of the topic hierarchies - this.hierarchyList = data.features.map(feature => { + this.topicList = data.features.map(feature => { if (feature.properties && feature.properties['wmo:topicHierarchy']) { return feature.properties['wmo:topicHierarchy'] } }); - console.log(this.hierarchyList) + console.log(this.topicList) } else { console.error("API response is not an object"); @@ -285,9 +311,15 @@ export default defineComponent({ "result": "Success", "messages transformed": 2, "messages published": 2, - "files": [ - "http://3.73.37.35/data/2023-12-17/wis/synop/test/WIGOS_0-20000-0-15015_20231217T120000.bufr4", - "http://3.73.37.35/data/2023-12-17/wis/synop/test/WIGOS_0-20000-0-15020_20231217T120000.bufr4" + "data_items": [ + { + "file_url": "http://3.127.235.197/data/2023-01-19/wis/synop/test/WIGOS_0-20000-0-64400_20230119T060000.bufr4", + "filename": "WIGOS_0-20000-0-64400_20230119T060000.bufr4" + }, + { + "data": "QlVGUgABgAQAABYAAAAAAAAAAAJuHgAH5wETBgAAAAALAAABgMGWx2AAAVMABOIAAANjQ0MDAAAAAAAAAAAAAAAIDIGxoaGBgAAAAAAAAAAAAAAAAAAAAPzimYBA/78kmTlBBU//////////////////////////////+dUnxn1P///////////26vbYOl////////////////////////////////////////////////////////////////AR////gJH///+T/x/+R/yf////////////7///v9f/////////////////////////////////+J/b/gAff2/4Dz/X/////////////////////////////////////7+kAH//v6QANnH////////////9+j//////////////v0f//////f//+/R/+////////////////////fo//////////////////3+oAP///////////////////8A3Nzc3", + "filename": "WIGOS_0-20000-0-64400_20230119T060000.bufr4" + } ], "warnings": [], "errors": [] @@ -300,10 +332,17 @@ export default defineComponent({ testPartialSuccessResult() { const testData = { "result": "Partial Success", - "messages transformed": 1, + "messages transformed": 2, "messages published": 1, - "files": [ - "http://3.73.37.35/data/2023-12-17/wis/synop/test/WIGOS_0-20000-0-15015_20231217T120000.bufr4" + "data_items": [ + { + "file_url": "http://3.127.235.197/data/2023-01-19/wis/synop/test/WIGOS_0-20000-0-64400_20230119T060000.bufr4", + "filename": "WIGOS_0-20000-0-64400_20230119T060000.bufr4" + }, + { + "data": "QlVGUgABgAQAABYAAAAAAAAAAAJuHgAH5wETBgAAAAALAAABgMGWx2AAAVMABOIAAANjQ0MDAAAAAAAAAAAAAAAIDIGxoaGBgAAAAAAAAAAAAAAAAAAAAPzimYBA/78kmTlBBU//////////////////////////////+dUnxn1P///////////26vbYOl////////////////////////////////////////////////////////////////AR////gJH///+T/x/+R/yf////////////7///v9f/////////////////////////////////+J/b/gAff2/4Dz/X/////////////////////////////////////7+kAH//v6QANnH////////////9+j//////////////v0f//////f//+/R/+////////////////////fo//////////////////3+oAP///////////////////8A3Nzc3", + "filename": "WIGOS_0-20000-0-64400_20230119T060000.bufr4" + } ], "warnings": [ "Missing station height for station 15090", @@ -320,7 +359,7 @@ export default defineComponent({ "result": "Failure", "messages transformed": 0, "messages published": 0, - "files": [], + "data_items": [], "warnings": [], "errors": [ "Error converting to BUFR: local variable 'messages' referenced before assignment", @@ -338,30 +377,38 @@ export default defineComponent({ year: this.date.year, // Year of data month: this.date.month + 1, // Month of data, +1 as JS starts // from 0 for months - channel: this.hierarchy, // Topic hierarchy + channel: this.topic, // Topic hierarchy notify: this.notificationsOn // Boolean for WIS2 notifications } }; - const synopUrl = `${import.meta.env.VITE_API_URL}/processes/wis2box-synop-process/execution` + const synopUrl = `${import.meta.env.VITE_API_URL}/processes/wis2box-synop2bufr/execution` - console.log(payload); - console.log(synopUrl); + //console.log(payload); + //console.log(synopUrl); this.input = payload; const response = await fetch(synopUrl, { method: 'POST', headers: { 'encode': 'json', - 'Content-Type': 'application/geo+json' + 'Content-Type': 'application/geo+json', + 'authorization': 'Bearer ' + this.token }, body: JSON.stringify(payload) }); if (!response.ok) { + let result; + if(response.status == 401) { + result = "Unauthorized, please provide a valid execution token" + } + else { + result = "API error" + } console.error('HTTP error', response.status); this.result = { - "result": "API error", + "result": result, "errors": [ synopUrl + " returned " + response.status ] @@ -369,8 +416,8 @@ export default defineComponent({ } else { const data = await response.json(); this.result = data; - console.log("Result:"); // TODO: Remove this line - console.log(this.result); // TODO: Remove this line + //console.log("Result:"); // TODO: Remove this line + //console.log(this.result); // TODO: Remove this line } }, // Method for when the user presses the submit button, including @@ -401,11 +448,6 @@ export default defineComponent({ // End loading animation this.loading = false; - }, - // Get filename from output BUFR files so it can be displayed on screen - getFileName(url) { - const urlParts = url.split('/'); - return urlParts[urlParts.length - 1]; } }, watch: { @@ -441,7 +483,7 @@ export default defineComponent({ DownloadButton }, mounted() { - this.fetchHierarchy(); + this.fetchTopics(); } }); @@ -452,6 +494,11 @@ export default defineComponent({ font-weight: 700; } +.small-title { + font-size: 1.1rem; + font-weight: 700; +} + .calendar-box { width: 250px; } @@ -516,5 +563,4 @@ export default defineComponent({ .divider-spacing { margin-top: 10px; -} - \ No newline at end of file +} \ No newline at end of file diff --git a/webapp/src/components/TopicSelector.vue b/webapp/src/components/TopicSelector.vue new file mode 100644 index 0000000..f8b07f9 --- /dev/null +++ b/webapp/src/components/TopicSelector.vue @@ -0,0 +1,8 @@ + diff --git a/webapp/src/layouts/default/AppBar.vue b/webapp/src/layouts/default/AppBar.vue index d6d806e..fe2cad5 100644 --- a/webapp/src/layouts/default/AppBar.vue +++ b/webapp/src/layouts/default/AppBar.vue @@ -1,6 +1,8 @@ diff --git a/webapp/src/views/Home.vue b/webapp/src/views/Home.vue index f685572..7434f64 100644 --- a/webapp/src/views/Home.vue +++ b/webapp/src/views/Home.vue @@ -1,6 +1,7 @@ \ No newline at end of file diff --git a/webapp/src/views/SynopForm.vue b/webapp/src/views/SynopForm.vue index 732f64f..c082d91 100644 --- a/webapp/src/views/SynopForm.vue +++ b/webapp/src/views/SynopForm.vue @@ -1,5 +1,5 @@