From f447005817adfe4fbc92b0bb99d3cae4a48f1f87 Mon Sep 17 00:00:00 2001 From: Charles Date: Fri, 10 Feb 2023 10:26:41 +0100 Subject: [PATCH] [WIFI-12286] Add support for new venue upgrade API Signed-off-by: Charles --- package-lock.json | 4 +- package.json | 2 +- public/locales/de/translation.json | 12 +- public/locales/en/translation.json | 12 +- public/locales/es/translation.json | 12 +- public/locales/fr/translation.json | 12 +- public/locales/pt/translation.json | 12 +- src/hooks/Network/Venues.ts | 133 ++++++++++---- src/pages/VenuePage/VenueCard/Actions.tsx | 49 +++--- .../VenueCard/VenueFirmwareUpgradeModal.tsx | 165 ++++++++++++++++++ 10 files changed, 341 insertions(+), 72 deletions(-) create mode 100644 src/pages/VenuePage/VenueCard/VenueFirmwareUpgradeModal.tsx diff --git a/package-lock.json b/package-lock.json index eb794e4..ac21b7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wlan-cloud-owprov-ui", - "version": "2.9.0(8)", + "version": "2.9.0(10)", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "wlan-cloud-owprov-ui", - "version": "2.9.0(8)", + "version": "2.9.0(10)", "license": "ISC", "dependencies": { "@chakra-ui/icons": "^2.0.11", diff --git a/package.json b/package.json index d13c059..2eb8811 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wlan-cloud-owprov-ui", - "version": "2.9.0(8)", + "version": "2.9.0(10)", "description": "", "main": "index.tsx", "scripts": { diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 8c8f65a..23684e5 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -630,6 +630,8 @@ "one": "Gerät", "reassign_already_owned": "Geräte neu zuweisen, die bereits vorhanden sind und einem anderen Unternehmen/Veranstaltungsort/Abonnenten gehören?", "restricted": "Beschränkt", + "restricted_overriden": "Dies ist ein eingeschränktes Gerät, aber es befindet sich im Entwicklungsmodus. Alle Einschränkungen werden derzeit ignoriert", + "restrictions_overriden_title": "Dev-Modus", "sanity": "Gesundheit", "start_import": "Geräteimport starten", "test_batch": "Testen Sie Importdaten", @@ -683,6 +685,13 @@ "update_success": "Entität aktualisiert!", "venues_under_root": "Veranstaltungsorte können nicht direkt unter der Root-Entität erstellt werden" }, + "firmware": { + "db_update_warning": "Dieser Vorgang wird täglich automatisch durchgeführt, ohne dass dieses manuelle Update verwendet werden muss. Die Aktualisierung dieser Datenbank kann bis zu 25 Minuten dauern", + "last_db_update_modal": "Firmware-Datenbank", + "last_db_update_title": "Datenbank", + "start_db_update": "Datenbankaktualisierung starten", + "started_db_update": "Datenbankaktualisierung gestartet, dieser Vorgang sollte bis zu 25 Minuten dauern" + }, "footer": { "powered_by": "Unterstützt von", "version": "Ausführung" @@ -1123,9 +1132,10 @@ "title": "Veranstaltungsorte", "update_all_devices": "Alle Gerätekonfigurationen aktualisieren", "update_success": "Veranstaltungsort aktualisiert!", - "upgrade_all_devices": "Aktualisieren Sie alle Geräte auf die neueste Firmware", + "upgrade_all_devices": "Aktualisieren Sie die Firmware aller Geräte", "upgrade_all_devices_error": "Fehler beim Aktualisieren von Geräten: {{e}}", "upgrade_all_devices_success": "Upgrade von Geräten erfolgreich gestartet!", + "upgrade_options_available": "Hier sind alle verfügbaren Revisionen, bitte wählen Sie diejenige aus, auf die ALLE Geräte dieses Veranstaltungsortes aktualisiert werden sollen", "use_existing": "Benutze existierendes", "use_existing_contacts": "Verwenden Sie vorhandene Kontakte", "use_this_contact": "Verwenden Sie diesen Kontakt" diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index f6b7e05..92ad3e3 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -630,6 +630,8 @@ "one": "Device", "reassign_already_owned": "Reassign devices which already exist and are owned by another entity/venue/subscriber?", "restricted": "Restricted", + "restricted_overriden": "This is a restricted device, but it is in development mode. All restrictions are currently ignored", + "restrictions_overriden_title": "Dev Mode", "sanity": "Sanity", "start_import": "Start Device Importation", "test_batch": "Test Import Data", @@ -683,6 +685,13 @@ "update_success": "Entity updated!", "venues_under_root": "Venues cannot be created directly under the root entity" }, + "firmware": { + "db_update_warning": "This operation is done daily automatically without need to use this manual update. Updating this database can take up to 25 minutes", + "last_db_update_modal": "Firmware Database", + "last_db_update_title": "Database", + "start_db_update": "Start Database Update", + "started_db_update": "Started database update, this operation should take up to 25 minutes to complete" + }, "footer": { "powered_by": "Powered By", "version": "Version" @@ -1123,9 +1132,10 @@ "title": "Venues", "update_all_devices": "Update All Device Configurations", "update_success": "Venue updated!", - "upgrade_all_devices": "Upgrade All Devices to Latest Firmware", + "upgrade_all_devices": "Upgrade All Devices Firmware", "upgrade_all_devices_error": "Error upgrading devices: {{e}}", "upgrade_all_devices_success": "Successfully started upgrading devices!", + "upgrade_options_available": "Here are all available revisions, please select the one you want ALL of this venue's devices to be upgrade to", "use_existing": "Use Existing", "use_existing_contacts": "Use Existing Contacts", "use_this_contact": "Use this contact" diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 1a5385b..8727e57 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -630,6 +630,8 @@ "one": "Dispositivo", "reassign_already_owned": "¿Reasignar dispositivos que ya existen y son propiedad de otra entidad/lugar/suscriptor?", "restricted": "Restringido", + "restricted_overriden": "Este es un dispositivo restringido, pero está en modo de desarrollo. Actualmente se ignoran todas las restricciones.", + "restrictions_overriden_title": "MODO DE DESARROLLO", "sanity": "Cordura", "start_import": "Iniciar la importación de dispositivos", "test_batch": "Datos de importación de prueba", @@ -683,6 +685,13 @@ "update_success": "¡Entidad actualizada!", "venues_under_root": "Los lugares no se pueden crear directamente bajo la entidad raíz" }, + "firmware": { + "db_update_warning": "Esta operación se realiza automáticamente todos los días de forma automática sin necesidad de utilizar esta actualización manual. La actualización de esta base de datos puede tardar hasta 25 minutos", + "last_db_update_modal": "Base de datos de firmware", + "last_db_update_title": "Base de datos", + "start_db_update": "Iniciar actualización de la base de datos", + "started_db_update": "Actualización de la base de datos iniciada, esta operación debería tardar hasta 25 minutos en completarse" + }, "footer": { "powered_by": "energizado por", "version": "Versión" @@ -1123,9 +1132,10 @@ "title": "Sedes", "update_all_devices": "Actualizar todas las configuraciones de dispositivos", "update_success": "Lugar actualizado!", - "upgrade_all_devices": "Actualice todos los dispositivos al firmware más reciente", + "upgrade_all_devices": "Actualizar el firmware de todos los dispositivos", "upgrade_all_devices_error": "Error al actualizar dispositivos: {{e}}", "upgrade_all_devices_success": "¡Comenzó con éxito la actualización de dispositivos!", + "upgrade_options_available": "Aquí están todas las revisiones disponibles, seleccione la que desea que TODOS los dispositivos de este lugar se actualicen", "use_existing": "Utilizar existente", "use_existing_contacts": "Usar contactos existentes", "use_this_contact": "Usa este contacto" diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 7339cc2..a11bdf2 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -630,6 +630,8 @@ "one": "Dispositif", "reassign_already_owned": "Réattribuer des appareils qui existent déjà et qui appartiennent à une autre entité/salle/abonné ?", "restricted": "Limité", + "restricted_overriden": "Il s'agit d'un appareil restreint, mais il est en mode développement. Toutes les restrictions sont actuellement ignorées", + "restrictions_overriden_title": "Mode développement", "sanity": "Santé mentale", "start_import": "Démarrer l'importation de l'appareil", "test_batch": "Tester les données d'importation", @@ -683,6 +685,13 @@ "update_success": "Entité mise à jour !", "venues_under_root": "Les lieux ne peuvent pas être créés directement sous l'entité racine" }, + "firmware": { + "db_update_warning": "Cette opération se fait automatiquement quotidiennement sans avoir besoin d'utiliser cette mise à jour manuelle. La mise à jour de cette base de données peut prendre jusqu'à 25 minutes", + "last_db_update_modal": "Base de données du micrologiciel", + "last_db_update_title": "Base de données", + "start_db_update": "Démarrer la mise à jour de la base de données", + "started_db_update": "Mise à jour de la base de données démarrée, cette opération devrait prendre jusqu'à 25 minutes" + }, "footer": { "powered_by": "Alimenté par", "version": "Version" @@ -1123,9 +1132,10 @@ "title": "Les lieux", "update_all_devices": "Mettre à jour toutes les configurations de périphérique", "update_success": "Lieu mis à jour !", - "upgrade_all_devices": "Mettre à niveau tous les appareils vers le dernier micrologiciel", + "upgrade_all_devices": "Mettre à niveau le micrologiciel de tous les appareils", "upgrade_all_devices_error": "Erreur lors de la mise à jour des appareils : {{e}}", "upgrade_all_devices_success": "La mise à niveau des appareils a démarré avec succès !", + "upgrade_options_available": "Voici toutes les révisions disponibles, veuillez sélectionner celle vers laquelle vous souhaitez que TOUS les appareils de ce lieu soient mis à niveau", "use_existing": "Utiliser l'existant", "use_existing_contacts": "Utiliser les contacts existants", "use_this_contact": "Utilisez ce contact" diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index 082b30c..14c9c8f 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -630,6 +630,8 @@ "one": "Dispositivo", "reassign_already_owned": "Reatribuir dispositivos que já existem e são de propriedade de outra entidade/local/assinante?", "restricted": "Restrito", + "restricted_overriden": "Este é um dispositivo restrito, mas está em modo de desenvolvimento. Todas as restrições são atualmente ignoradas", + "restrictions_overriden_title": "Modo de desenvolvedor", "sanity": "Sanidade", "start_import": "Iniciar importação de dispositivos", "test_batch": "Dados de importação de teste", @@ -683,6 +685,13 @@ "update_success": "Entidade atualizada!", "venues_under_root": "Os locais não podem ser criados diretamente na entidade raiz" }, + "firmware": { + "db_update_warning": "Esta operação é feita automaticamente diariamente sem necessidade de usar esta atualização manual. A atualização deste banco de dados pode levar até 25 minutos", + "last_db_update_modal": "banco de dados de firmware", + "last_db_update_title": "base de dados", + "start_db_update": "Iniciar atualização do banco de dados", + "started_db_update": "Atualização do banco de dados iniciada, esta operação deve levar até 25 minutos para ser concluída" + }, "footer": { "powered_by": "Distribuído por", "version": "Versão" @@ -1123,9 +1132,10 @@ "title": "Locais", "update_all_devices": "Atualizar todas as configurações do dispositivo", "update_success": "Local atualizado!", - "upgrade_all_devices": "Atualize todos os dispositivos para o firmware mais recente", + "upgrade_all_devices": "Atualize o firmware de todos os dispositivos", "upgrade_all_devices_error": "Erro ao atualizar dispositivos: {{e}}", "upgrade_all_devices_success": "Atualização de dispositivos iniciada com sucesso!", + "upgrade_options_available": "Aqui estão todas as revisões disponíveis, selecione aquela para a qual você deseja que TODOS os dispositivos deste local sejam atualizados", "use_existing": "Usar existente", "use_existing_contacts": "Usar contatos existentes", "use_this_contact": "Use este contato" diff --git a/src/hooks/Network/Venues.ts b/src/hooks/Network/Venues.ts index dba4227..7592c12 100644 --- a/src/hooks/Network/Venues.ts +++ b/src/hooks/Network/Venues.ts @@ -1,22 +1,43 @@ import { useToast } from '@chakra-ui/react'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { v4 as uuid } from 'uuid'; -import useDefaultPage from 'hooks/useDefaultPage'; +import useDefaultPage from '../useDefaultPage'; import { AxiosError } from 'models/Axios'; -import { Venue } from 'models/Venue'; +import { DeviceRules } from 'models/Basic'; +import { Note } from 'models/Note'; import { axiosProv } from 'utils/axiosInstances'; +export interface VenueApiResponse { + id: string; + name: string; + description: string; + parent: string; + devices: string[]; + children: string[]; + contacts: string[]; + entity: string; + boards: string[]; + created: number; + modified: number; + configurations: string[]; + notes: Note[]; + variables: string[]; + location: string; + sourceIP: string[]; + deviceRules: DeviceRules; +} + const getVenuesBatch = async (limit: number, offset: number) => axiosProv .get(`venue?withExtendedInfo=true&offset=${offset}&limit=${limit}`) - .then(({ data }) => data.venues as Venue[]); + .then(({ data }) => data.venues as VenueApiResponse[]); const getAllVenues = async () => { const limit = 500; let offset = 0; - let data: Venue[] = []; - let lastResponse: Venue[] = []; + let data: VenueApiResponse[] = []; + let lastResponse: VenueApiResponse[] = []; do { // eslint-disable-next-line no-await-in-loop lastResponse = await getVenuesBatch(limit, offset); @@ -81,15 +102,16 @@ export const useGetSelectVenues = ({ select }: { select: string[] }) => { ); }; -export const useGetVenue = ({ id }: { id: string }) => { +export const useGetVenue = ({ id }: { id?: string }) => { const { t } = useTranslation(); const toast = useToast(); const goToDefaultPage = useDefaultPage(); return useQuery( ['get-venue', id], - () => axiosProv.get(`venue/${id}?withExtendedInfo=true`).then(({ data }) => data), + () => axiosProv.get(`venue/${id}?withExtendedInfo=true`).then(({ data }: { data: VenueApiResponse }) => data), { + enabled: id !== undefined && id !== '', onError: (e: AxiosError) => { if (!toast.isActive('venue-fetching-error')) toast({ @@ -104,7 +126,7 @@ export const useGetVenue = ({ id }: { id: string }) => { isClosable: true, position: 'top-right', }); - goToDefaultPage(); + if (e.response?.data?.ErrorCode === 404) goToDefaultPage(); }, }, ); @@ -115,10 +137,25 @@ export const useCreateVenue = () => axiosProv.post(`venue/0${createObjects ? `?createObjects=${JSON.stringify(createObjects)}` : ''}`, params), ); -export const useUpdateVenue = ({ id }: { id: string }) => - useMutation(({ params, createObjects }: { params: unknown; createObjects: unknown }) => - axiosProv.put(`venue/${id}${createObjects ? `?createObjects=${JSON.stringify(createObjects)}` : ''}`, params), +export const useUpdateVenue = ({ id }: { id: string }) => { + const queryClient = useQueryClient(); + + return useMutation( + ({ params, createObjects }: { params: Partial; createObjects?: unknown }) => + axiosProv + .put(`venue/${id}${createObjects ? `?createObjects=${JSON.stringify(createObjects)}` : ''}`, params) + .then((res: { data: VenueApiResponse }) => res), + { + onSuccess: ({ data }) => { + queryClient.invalidateQueries(['get-entity-tree']); + queryClient.invalidateQueries(['get-venues']); + queryClient.invalidateQueries(['get-all-locations', id]); + queryClient.setQueryData(['get-venue', id], data); + }, + }, ); +}; + export const useUpdateVenueDevices = ({ id }: { id: string }) => { const { t } = useTranslation(); const toast = useToast(); @@ -185,36 +222,38 @@ export const useRebootVenueDevices = ({ id }: { id: string }) => { }); }; -export const useUpgradeVenueDevices = ({ id }: { id: string }) => { +export const useUpgradeVenueDevices = () => { const { t } = useTranslation(); const toast = useToast(); - return useMutation(() => axiosProv.put(`venue/${id}?upgradeAllDevices=true`, {}), { - onSuccess: () => { - toast({ - id: 'venue-upgrade-devices-success', - title: t('common.success'), - description: t('venues.upgrade_all_devices_success'), - status: 'success', - duration: 5000, - isClosable: true, - position: 'top-right', - }); + return useMutation( + (data: { id: string; revision: string }) => + axiosProv.put(`venue/${data.id}?upgradeAllDevices=true&revision=${data.revision}`, {}), + { + onSuccess: () => { + toast({ + id: 'venue-upgrade-devices-success', + title: t('common.success'), + description: t('venues.upgrade_all_devices_success'), + status: 'success', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + }, + onError: (e: AxiosError) => { + toast({ + id: uuid(), + title: t('common.error'), + description: e?.response?.data?.ErrorDescription, + status: 'error', + duration: 5000, + isClosable: true, + position: 'top-right', + }); + }, }, - onError: (e: AxiosError) => { - toast({ - id: uuid(), - title: t('common.error'), - description: t('crud.upgrade_all_devices_error', { - e: e?.response?.data?.ErrorDescription, - }), - status: 'error', - duration: 5000, - isClosable: true, - position: 'top-right', - }); - }, - }); + ); }; export const useDeleteVenue = () => useMutation((id) => axiosProv.delete(`venue/${id}`)); @@ -271,3 +310,21 @@ export const useRemoveVenueContact = ({ }, ); }; + +type Release = { + date: number; + revision: string; +}; +const getVenueUpgradeAvailableFirmware = (id: string) => + axiosProv.put(`venue/${id}?upgradeAllDevices=true&revisionsAvailable=true`, {}).then( + (res: { + data: { + releases: Release[]; + releasesCandidates: Release[]; + developmentReleases: Release[]; + }; + }) => res.data, + ); + +export const useGetVenueUpgradeAvailableFirmware = ({ id }: { id: string }) => + useQuery(['venue', id, 'availableFirmware'], () => getVenueUpgradeAvailableFirmware(id)); diff --git a/src/pages/VenuePage/VenueCard/Actions.tsx b/src/pages/VenuePage/VenueCard/Actions.tsx index d650d48..8869cf1 100644 --- a/src/pages/VenuePage/VenueCard/Actions.tsx +++ b/src/pages/VenuePage/VenueCard/Actions.tsx @@ -1,24 +1,20 @@ import React from 'react'; -import { ChevronDownIcon } from '@chakra-ui/icons'; -import { Button, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; +import { IconButton, Menu, MenuButton, MenuItem, MenuList, Tooltip, useDisclosure } from '@chakra-ui/react'; +import { Wrench } from 'phosphor-react'; import { useTranslation } from 'react-i18next'; -import { useRebootVenueDevices, useUpdateVenueDevices, useUpgradeVenueDevices } from 'hooks/Network/Venues'; +import VenueFirmwareUpgradeModal from './VenueFirmwareUpgradeModal'; +import { useRebootVenueDevices, useUpdateVenueDevices } from 'hooks/Network/Venues'; -interface Props { +type Props = { venueId: string; isDisabled: boolean; -} +}; -const VenueActions = ( - { - venueId, - isDisabled - }: Props -) => { +const VenueActions = ({ venueId, isDisabled }: Props) => { const { t } = useTranslation(); + const { isOpen: isUpgradeOpen, onOpen: onOpenUpgrade, onClose: onCloseUpgrade } = useDisclosure(); const { mutateAsync: rebootDevices } = useRebootVenueDevices({ id: venueId }); const updateDevices = useUpdateVenueDevices({ id: venueId }); - const upgradeDevices = useUpgradeVenueDevices({ id: venueId }); const handleUpdateClick = () => { updateDevices.mutateAsync(); @@ -28,21 +24,22 @@ const VenueActions = ( rebootDevices(); }; - const handleUpgradeDevices = () => { - upgradeDevices.mutateAsync(); - }; - return ( - - } ml={2} isDisabled={isDisabled}> - {t('common.actions')} - - - {t('venues.reboot_all_devices')} - {t('venues.update_all_devices')} - {t('venues.upgrade_all_devices')} - - + <> + + + } ml={2} isDisabled={isDisabled}> + {t('common.actions')} + + + + {t('venues.reboot_all_devices')} + {t('venues.update_all_devices')} + {t('venues.upgrade_all_devices')} + + + + ); }; diff --git a/src/pages/VenuePage/VenueCard/VenueFirmwareUpgradeModal.tsx b/src/pages/VenuePage/VenueCard/VenueFirmwareUpgradeModal.tsx new file mode 100644 index 0000000..88b8240 --- /dev/null +++ b/src/pages/VenuePage/VenueCard/VenueFirmwareUpgradeModal.tsx @@ -0,0 +1,165 @@ +import * as React from 'react'; +import { + Alert, + AlertDescription, + AlertIcon, + Box, + Button, + Center, + ListItem, + Spinner, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + UnorderedList, +} from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import FormattedDate from 'components/FormattedDate'; +import { Modal } from 'components/Modals/Modal'; +import { useGetVenueUpgradeAvailableFirmware, useUpgradeVenueDevices } from 'hooks/Network/Venues'; +import { AxiosError } from 'models/Axios'; + +type Props = { + isOpen: boolean; + onClose: () => void; + venueId: string; +}; + +const VenueFirmwareUpgradeModal = ({ isOpen, onClose, venueId }: Props) => { + const { t } = useTranslation(); + const getAvailableFirmware = useGetVenueUpgradeAvailableFirmware({ id: venueId }); + const upgrade = useUpgradeVenueDevices(); + const [selectedRevision, setSelectedRevision] = React.useState(); + + const onRevisionSelect = (revision: string) => () => { + setSelectedRevision(revision); + }; + + const onUpgradeClick = () => { + if (selectedRevision) { + upgrade.mutateAsync( + { revision: selectedRevision, id: venueId }, + { + onSuccess: () => { + setSelectedRevision(undefined); + onClose(); + }, + }, + ); + } + }; + + const listItemStyle = (revision: string) => ({ + cursor: 'pointer', + backgroundColor: revision === selectedRevision ? 'gray.200' : 'white', + }); + + const displayRevision = (release: { date: number; revision: string }) => ( + + + {release.revision} + + ); + + const placeholder = React.useMemo(() => { + if (getAvailableFirmware.isFetching) { + return ( +
+ +
+ ); + } + + if (getAvailableFirmware.isError) { + return ( +
+ + + + {(getAvailableFirmware.error as AxiosError).response?.data?.ErrorDescription} + + +
+ ); + } + + return null; + }, [getAvailableFirmware]); + + React.useEffect(() => { + if (isOpen) { + setSelectedRevision(undefined); + getAvailableFirmware.refetch(); + } + }, [isOpen]); + + return ( + + + {placeholder || !getAvailableFirmware.data ? ( + placeholder + ) : ( + <> + {t('venues.upgrade_options_available')} +
+ +
+ + + Official Releases + Release Candidates + Dev Releases + + + + + Official Releases + + + {getAvailableFirmware.data?.releases + .sort((a, b) => b.date - a.date) + .map((release) => displayRevision(release))} + + + + + Release Candidates + + + {getAvailableFirmware.data?.releasesCandidates + .sort((a, b) => b.date - a.date) + .map((release) => displayRevision(release))} + + + + + Dev Releases + + + {getAvailableFirmware.data?.developmentReleases + .sort((a, b) => b.date - a.date) + .map((release) => displayRevision(release))} + + + + + + )} +
+
+ ); +}; + +export default VenueFirmwareUpgradeModal;