diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c68b43c7..b5677971b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,9 +73,9 @@ jobs: pip install -U ${{ matrix.django-version }} sudo npm install -g prettier - - name: Start InfluxDB and redis + - name: Start redis if: ${{ !cancelled() && steps.deps.conclusion == 'success' }} - run: docker compose up -d postgres redis + run: docker compose up -d redis - name: QA checks run: | @@ -83,11 +83,7 @@ jobs: - name: Tests if: ${{ !cancelled() && steps.deps.conclusion == 'success' }} - run: | - coverage run runtests.py --parallel - SAMPLE_APP=1 coverage run ./runtests.py --parallel --exclude-tag=selenium_tests - coverage combine - coverage xml + run: ./runtests env: SELENIUM_HEADLESS: 1 GECKO_LOG: 1 diff --git a/.gitignore b/.gitignore index fe98efe8f..858291276 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ coverage.xml tests/openwisp2/media/ tests/openwisp2/private/ !tests/openwisp2/private/firmware/fake-img*.bin +tests/openwisp2/firmware/* # Sphinx documentation docs/_build/ diff --git a/docker-compose.yml b/docker-compose.yml index 1424fbfd2..b3ec96001 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,6 @@ -version: "3" - services: redis: image: redis:alpine ports: - "6379:6379" entrypoint: redis-server --appendonly yes - - postgres: - image: postgis/postgis:15-3.4-alpine - environment: - POSTGRES_PASSWORD: openwisp2 - POSTGRES_USER: openwisp2 - POSTGRES_DB: openwisp2 - ports: - - 5432:5432 diff --git a/docs/developer/index.rst b/docs/developer/index.rst index e7998462c..a22193797 100644 --- a/docs/developer/index.rst +++ b/docs/developer/index.rst @@ -8,6 +8,7 @@ Developer Docs ./installation.rst ./extending.rst + ./utils.rst Other useful resources: diff --git a/docs/developer/installation.rst b/docs/developer/installation.rst index 58ba66ad1..95cc088d1 100644 --- a/docs/developer/installation.rst +++ b/docs/developer/installation.rst @@ -41,11 +41,11 @@ Navigate into the cloned repository: .. _firmware_upgrader_dev_docker: -Launch Redis and PostgreSQL: +Launch Redis: .. code-block:: shell - docker compose up -d redis postgres + docker compose up -d redis Setup and activate a virtual-environment (we'll be using `virtualenv `_): @@ -99,7 +99,7 @@ windows are needed): celery -A openwisp2 worker -l info celery -A openwisp2 beat -l info -Run quality assurance tests with: +Run QA checks with: .. code-block:: shell @@ -110,31 +110,16 @@ Run tests with (make sure you have the :ref:`selenium dependencies .. code-block:: shell - # standard tests - ./runtests.py + ./runtests -Some tests, such as the Selenium UI tests, require a PostgreSQL database -to run. If you don't have a PostgreSQL database running on your system, -you can use the :ref:`Docker Compose configuration provided in this -repository `. Once set up, you can run these -specific tests as follows: +``./runtests`` is a wrapper that runs tests for both +``openwisp_firmware_upgrader`` and the SAMPLE_APP. To run only the +SAMPLE_APP tests: .. code-block:: shell - # Run only specific selenium tests classes - cd tests/ - DJANGO_SETTINGS_MODULE=openwisp2.postgresql_settings ./manage.py test openwisp_firmware_upgrader.tests.test_selenium.TestDeviceAdmin - - # tests for the sample app SAMPLE_APP=1 ./runtests.py --keepdb --failfast -When running the last line of the previous example, the environment -variable ``SAMPLE_APP`` activates the app in -``/tests/openwisp2/sample_firmware_upgrader/`` which is a simple django -app that extends ``openwisp-firmware-upgrader`` with the sole purpose of -testing its extensibility, for more information regarding this concept, -read :doc:`extending`. - .. important:: If you want to add ``openwisp-firmware-upgrader`` to an existing diff --git a/docs/developer/utils.rst b/docs/developer/utils.rst new file mode 100644 index 000000000..3bf175fc9 --- /dev/null +++ b/docs/developer/utils.rst @@ -0,0 +1,31 @@ +Code Utilities +============== + +.. include:: ../partials/developer-docs.rst + +.. contents:: **Table of Contents**: + :depth: 2 + :local: + +Signals +------- + +.. include:: /partials/signals-note.rst + +``firmware_upgrader_log_updated`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Path**: +``openwisp_firmware_upgrader.signals.firmware_upgrader_log_updated`` + +**Arguments**: + +- ``sender``: the model class that sent the signal (``UpgradeOperation``) +- ``instance``: instance of ``UpgradeOperation`` which got its log updated +- ``line``: the new log line that was appended +- ``**kwargs``: additional keyword arguments + +This signal is emitted when the log content of an upgrade operation is +updated. You can use this signal to perform custom actions when log +updates occur, such as sending notifications, updating external systems, +or logging to custom destinations. diff --git a/docs/index.rst b/docs/index.rst index e98180f02..8b27215c8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -38,9 +38,11 @@ within the OpenWISP architecture. ./user/intro.rst ./user/quickstart.rst + ./user/upgrade-status.rst ./user/automatic-device-firmware-detection.rst ./user/custom-firmware-upgrader.rst ./user/rest-api.rst + ./user/websocket-api.rst ./user/settings.rst .. toctree:: diff --git a/docs/user/intro.rst b/docs/user/intro.rst index 0d06c915e..bf533d924 100644 --- a/docs/user/intro.rst +++ b/docs/user/intro.rst @@ -9,7 +9,8 @@ Firmware Upgrader: Features or not - Prevents accidental multiple upgrades using the same firmware image - Single device upgrade -- Mass upgrades +- Mass upgrades with possibility of filtering by device group and/or + geographic location - Possibility to divide firmware images in categories - :doc:`REST API ` - :doc:`Possibility of writing custom upgraders diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst index 907ce87e4..50854b79e 100644 --- a/docs/user/quickstart.rst +++ b/docs/user/quickstart.rst @@ -98,8 +98,8 @@ it. 4. Perform a Firmware Upgrade to a Specific Device -------------------------------------------------- -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/quickstart-devicefirmware.gif - :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/quickstart-devicefirmware.gif +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-devicefirmware.gif + :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-devicefirmware.gif Once a new build is ready, has been created in the system and its image have been uploaded, it will be the time to finally upgrade our devices. @@ -110,15 +110,39 @@ then go to the "Firmware" tab. If you correctly filled **OS identifier** in step 2, you should have a situation similar to the one above: in this example, the device is using version ``1.0`` and we want to upgrade it to version ``2.0``, once the new -firmware image is selected we just have to hit save, then a new tab will -appear in the device page which allows us to see what's going on during -the upgrade. +firmware image is selected we just have to hit save, then a new tab +"Recent Firmware Upgrades" will appear in the device page which allows us +to observe in real time what's going on during the upgrade without +requiring page reloads. -Right now, the update of the upgrade information is not asynchronous yet, -so you will have to reload the page periodically to find new information. -This will be addressed in a future release. +5. Canceling a Firmware Upgrade +------------------------------- -5. Performing Mass Upgrades +If you need to cancel a firmware upgrade that is currently in progress on +a specific device, you can do so from the device's firmware upgrade page. + +To cancel an ongoing upgrade: + +- Navigate to the device details page +- Go to the "Firmware" tab +- If an upgrade is in progress, you will see a "Cancel Upgrade" button +- Click the "Cancel Upgrade" button to stop the upgrade process + +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-cancel-upgrade.gif + :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-cancel-upgrade.gif + +Cancellation is only possible during the early stages of the upgrade +process. Once the firmware flashing begins, the cancel button will be +disabled and the upgrade cannot be stopped, as interrupting the flashing +process could corrupt the device. + +The system automatically prevents cancellation during critical phases to +ensure device safety. + +Once cancelled, the upgrade status will be updated to show that the +operation was cancelled, and you can attempt a new upgrade if needed. + +6. Performing Mass Upgrades --------------------------- Before proceeding, please ensure the following preconditions are met: @@ -134,16 +158,24 @@ At this stage you can try a mass upgrade by doing the following: devices to be upgraded with - click on "Mass-upgrade devices related to the selected build". -.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/quickstart-batch-upgrade.gif - :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/quickstart-batch-upgrade.gif +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-batch-upgrade.gif + :target: https://raw.githubusercontent.com/openwisp/openwisp-firmware-upgrader/docs/docs/images/1.3/quickstart-batch-upgrade.gif At this point you should see a summary page which will inform you of which -devices are going to be upgraded, you can either confirm the operation or -cancel. +devices are going to be upgraded. On this page, you can optionally filter +the devices to be upgraded by: -Once the operation is confirmed you will be redirected to a page in which -you can monitor the progress of the upgrade operations. +- **Device Group**: limit the upgrade to devices belonging to a specific + group +- **Geographic Location**: limit the upgrade to devices at a specific + location + +These filters allow for more granular control over which devices are +upgraded, making it easier to perform staged rollouts or target specific +subsets of your device fleet. -Right now, the update of the upgrade information is not asynchronous yet, -so you will have to reload the page periodically to find new information. -This will be addressed in a future release. +After reviewing the selection and setting any desired filters, you can +either confirm the operation or cancel. + +Once the operation is confirmed you will be redirected to a page in which +you can monitor the progress of the upgrade operations in real time. diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index 963e63413..bfa10e1f4 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -83,7 +83,7 @@ List Mass Upgrade Operations The list of batch upgrade operations provides the following filters: - ``build`` (Firmware build ID) -- ``status`` (One of: idle, in-progress, success, failed) +- ``status`` (One of: idle, in-progress, success, failed, cancelled) Here's a few examples: @@ -210,6 +210,25 @@ Upgrades all the devices related to the specified build ID. POST /api/v1/firmware-upgrader/build/{id}/upgrade/ +**Optional Parameters** + +The batch upgrade operation accepts the following optional parameters in +the request body: + +- ``group`` (Device group ID): limit the upgrade to devices belonging to a + specific group +- ``location`` (Location ID): limit the upgrade to devices at a specific + geographic location + +Example with filters: + +.. code-block:: json + + { + "group": "{group_id}", + "location": "{location_id}" + } + Dry-run Batch Upgrade ~~~~~~~~~~~~~~~~~~~~~ @@ -223,6 +242,25 @@ exists for a device which would be upgraded. GET /api/v1/firmware-upgrader/build/{id}/upgrade/ +**Optional Query Parameters** + +The dry-run batch upgrade operation accepts the following optional query +parameters: + +- ``group`` (Device group ID): limit the preview to devices belonging to a + specific group +- ``location`` (Location ID): limit the preview to devices at a specific + geographic location + +If both ``group`` and ``location`` are provided, only devices matching +both filters are included. + +Example with filters: + +.. code-block:: text + + GET /api/v1/firmware-upgrader/build/{id}/upgrade/?group={group_id}&location={location_id} + List Firmware Categories ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -280,7 +318,7 @@ The list of upgrade operations provides the following filters: - ``device__organization_slug`` (Organization slug of the device) - ``device`` (Device ID) - ``image`` (Firmware image ID) -- ``status`` (One of: in-progress, success, failed, aborted) +- ``status`` (One of: in-progress, success, failed, aborted, cancelled) Here's a few examples: @@ -299,6 +337,18 @@ Get Upgrade Operation Details GET /api/v1/firmware-upgrader/upgrade-operation/{id} +Cancel Upgrade Operation +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + POST /api/v1/firmware-upgrader/upgrade-operation/{id}/cancel/ + +.. note:: + + This endpoint may return a 409 status code if the operation cannot be + cancelled. + List Device Upgrade Operations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -309,7 +359,7 @@ List Device Upgrade Operations **Available filters** The list of device upgrade operations can be filtered by ``status`` (one -of: in-progress, success, failed, aborted). +of: in-progress, success, failed, aborted, cancelled). .. code-block:: text diff --git a/docs/user/upgrade-status.rst b/docs/user/upgrade-status.rst new file mode 100644 index 000000000..630de19a2 --- /dev/null +++ b/docs/user/upgrade-status.rst @@ -0,0 +1,185 @@ +Upgrade Status +============== + +OpenWISP Firmware Upgrader tracks the progress of firmware upgrade +operations through different status values. Understanding these statuses +is helpful for monitoring upgrade operations and troubleshooting issues. + +.. contents:: **Table of contents**: + :depth: 2 + :local: + +Upgrade Operation Status Reference +---------------------------------- + +In Progress +~~~~~~~~~~~ + +**Status**: ``in-progress`` + +**Description**: The upgrade operation is currently running. This includes +all phases of the upgrade process: device connection, firmware validation, +file upload, and firmware flashing. + +**What happens during this status:** + +- Device identity verification +- Firmware image validation +- Some non-critical services may be stopped to free up memory, if needed +- Image upload to the device +- Firmware flashing process + +**User Actions**: Users can cancel upgrade operations that are in +progress, but only before the firmware flashing phase begins (typically +when progress is below 65%). + +Success +~~~~~~~ + +**Status**: ``success`` + +**Description**: The firmware upgrade completed successfully. The device +has been upgraded to the new firmware version and is functioning properly. + +**What this means:** + +- The firmware was successfully flashed to the device +- The device rebooted with the new firmware +- Connectivity was restored after the upgrade +- All verification checks passed + +**Next Steps**: No action required. The upgrade is complete and the device +is running the new firmware. + +Failed +~~~~~~ + +**Status**: ``failed`` + +**Description**: The upgrade operation completed, but the system could not +reach the device again after the upgrade. + +**Common causes:** + +- Hardware failures +- Unexpected system errors +- The network became unreachable after flashing the new firmware + +**Recommended Actions**: + +- Check network connectivity +- Physical inspection and/or serial console debugging + +Aborted +~~~~~~~ + +**Status**: ``aborted`` + +**Description**: The upgrade operation was stopped due to prerequisites +not being met. The system determined it was unsafe or impossible to +proceed with the upgrade. + +**Common causes:** + +- Device UUID mismatch (wrong device targeted) +- Insufficient memory on the device +- Invalid or corrupted firmware image + +**What happens when aborted:** + +- The upgrade stops immediately +- If services were stopped to free up memory, they are automatically + restarted +- No firmware changes are made to the device +- Device remains in its original state + +**Recommended Actions**: + +- Verify the correct device is selected +- Check firmware image compatibility +- Ensure device has sufficient memory + +Cancelled +~~~~~~~~~ + +**Status**: ``cancelled`` + +**Description**: The upgrade operation was manually stopped by the user +before completion. This is a deliberate action taken through the admin +interface or REST API. + +Users can cancel upgrades through the admin interface using the "Cancel" +button that appears next to in-progress operations. + +**When cancellation is possible:** + +- During the early stages of upgrade (typically before 65% progress) +- Before the new firmware image is written to the flash memory of the + network device +- While the operation status is still "in-progress" + +**What happens when the upgrade operation is cancelled:** + +- The upgrade process stops immediately +- If services were stopped during the upgrade, they are automatically + restarted +- No firmware changes are made to the device +- Device remains in its original state + +Status Flow +----------- + +A firmware upgrade operation always starts in the ``in-progress`` state. +From there, it can transition into one of several terminal states +depending on how the operation concludes. + +Successful Flow +~~~~~~~~~~~~~~~ + +In the normal case, the upgrade proceeds without interruption: + +1. ``in-progress``: the upgrade is executed; +2. ``success``: the device reboots and becomes reachable again. + +This indicates a fully completed and verified upgrade. + +Interrupted or Unsuccessful Flows +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An upgrade may also end prematurely or unsuccessfully: + +- ``aborted``: the system detects that one or more safety preconditions + are not met *before* flashing begins and stops the operation without + making any changes to the device; +- ``cancelled``: the user manually stops the upgrade while it is still + safe to do so, firmware flash is prevented; +- ``failed``: the firmware flashing process completes, but the device does + not become reachable afterward, it usually indicates a post-flash + failure. + +Terminal States +~~~~~~~~~~~~~~~ + +The following statuses are terminal and will not transition further: + +- ``success`` +- ``failed`` +- ``aborted`` +- ``cancelled`` + +Once a terminal state is reached, a new upgrade operation must be +initiated to retry or recover. + +Monitoring Upgrades +------------------- + +**Real-time Progress**: The admin interface provides real-time updates of +upgrade operations, including progress percentages and detailed logs. See +:doc:`websocket-api` for details on the WebSocket API used to deliver +these updates. + +**Upgrade Logs**: Each status change is logged with detailed information +about what occurred during the upgrade process. + +**Batch Operations**: When performing mass upgrades, you can monitor the +status of individual device upgrades within the batch operation. diff --git a/docs/user/websocket-api.rst b/docs/user/websocket-api.rst new file mode 100644 index 000000000..743357956 --- /dev/null +++ b/docs/user/websocket-api.rst @@ -0,0 +1,246 @@ +WebSocket Reference +=================== + +.. contents:: **Table of contents**: + :depth: 2 + :local: + +Overview +-------- + +The WebSocket API provides real-time updates for firmware upgrade +operations. + +All endpoints: + +- Use JSON messages. +- Support requesting the current state of the connection scope. +- May push real-time updates after the connection is established. + +Authentication and Authorization +-------------------------------- + +All WebSocket endpoints require an authenticated user. + +A connection is accepted only if the user is authorized to access the +requested resource. The connection is closed immediately if authorization +fails. + +A user is authorized if: + +- The user is a superuser, OR +- The user: + + - Is marked as staff, + - Has either ``view`` or ``change`` permission on the relevant object, + - Is an organization admin (manager) for the object's organization. + +Connection Endpoints +-------------------- + +1. Upgrade Operation +~~~~~~~~~~~~~~~~~~~~ + +Connection URL: + +:: + + wss:///ws/firmware-upgrader/upgrade-operation// + +Scope ++++++ + +A single upgrade operation. + +Client Message +++++++++++++++ + +To request the current state of the operation: + +.. code-block:: javascript + + { + "type": "request_current_state", // Required. Requests current operation state. + "operation_id": "" // Must match the in the URL. + } + +.. warning:: + + Any other message type is ignored. + +When the client sends ``request_current_state``, the server responds with +exactly one message: + +.. code-block:: javascript + + { + "type": "operation_update", // Message type identifier + "operation": { + "id": "", // Operation identifier + "device": "", // Device identifier + "image": "", // Firmware image identifier + "status": "", // Current operation status + "log": "", // Operation log output + "progress": , // Progress percentage (0–100) + "modified": "", // Last modification timestamp (ISO 8601) + "created": "" // Creation timestamp (ISO 8601) + } + } + +Real-time Updates ++++++++++++++++++ + +After the connection is established, the server pushes +``operation_update`` messages whenever the operation state changes. + +The message structure is identical to the response returned for +``request_current_state``. + +2. Batch Upgrade Operation +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Connection URL: + +:: + + wss:///ws/firmware-upgrader/batch-upgrade-operation// + +Scope ++++++ + +A batch upgrade containing multiple operations. + +Client Message +++++++++++++++ + +To request the current state of the batch: + +.. code-block:: javascript + + { + "type": "request_current_state", // Required. Requests current batch state. + "batch_id": "" // Must match the in the URL. + } + +.. warning:: + + Any other message type is ignored. + +When the client sends ``request_current_state``, the server responds with +exactly one message: + +.. code-block:: javascript + + { + "type": "batch_state", // Message type identifier + "batch_status": { + "status": "", // Overall batch status + "completed": , // Number of completed operations + "total": // Total operations in the batch + }, + "operations": [ + { + "id": "", // Operation identifier + "device": "", // Device identifier + "image": "", // Firmware image identifier + "status": "", // Operation status + "log": "", // Operation log output + "progress": , // Progress percentage (0–100) + "modified": "", // Last modification timestamp + "created": "" // Creation timestamp + } + ] + } + +Real-time Updates ++++++++++++++++++ + +The endpoint may push: + +``operation_progress`` + +.. code-block:: javascript + + { + "type": "operation_progress", // Message type identifier + "operation_id": "", // Operation identifier + "status": "", // Operation status + "progress": , // Progress percentage (0–100) + "modified": "", // Last modification timestamp + "device_id": "", // Device identifier + "device_name": "", // Device display name + "image_name": "" // Firmware image display name + } + +``batch_status`` + +.. code-block:: javascript + + { + "type": "batch_status", // Message type identifier + "status": "", // Overall batch status + "completed": , // Number of completed operations + "total": // Total operations in the batch + } + +3. Device Upgrade +~~~~~~~~~~~~~~~~~ + +Connection URL: + +:: + + wss:///ws/firmware-upgrader/device// + +Scope ++++++ + +Recent and ongoing upgrade operations for a device. + +Client Message +++++++++++++++ + +To request the current state for the device: + +.. code-block:: javascript + + { + "type": "request_current_state", // Required. Requests device upgrade state. + "device_id": "" // Must match the in the URL. + } + +.. warning:: + + Any other message type is ignored. + +When ``request_current_state`` is sent, the server sends up to five +separate messages (one per operation). + +Each message uses the following envelope: + +.. code-block:: javascript + + { + "model": "UpgradeOperation", // Model identifier + "data": { + "type": "operation_update", // Message type identifier + "operation": { + "id": "", // Operation identifier + "device": "", // Device identifier + "image": "", // Firmware image identifier + "status": "", // Operation status + "log": "", // Operation log output + "progress": , // Progress percentage (0–100) + "modified": "" // Last modification timestamp + } + } + } + +Real-time Updates ++++++++++++++++++ + +After the connection is established, the server forwards +``operation_update`` events for upgrade operations related to the device. + +Real-time messages use the same envelope structure as described above and +are emitted individually as operation state changes occur. diff --git a/openwisp_firmware_upgrader/admin.py b/openwisp_firmware_upgrader/admin.py index 3a99b9f92..6e07f1e63 100644 --- a/openwisp_firmware_upgrader/admin.py +++ b/openwisp_firmware_upgrader/admin.py @@ -9,6 +9,7 @@ from django.contrib import admin, messages from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.core.exceptions import ValidationError +from django.core.paginator import InvalidPage, Paginator from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import redirect from django.template.response import TemplateResponse @@ -28,12 +29,15 @@ from .filters import ( BuildCategoryFilter, BuildCategoryOrganizationFilter, + BuildFilter, CategoryFilter, CategoryOrganizationFilter, + GroupFilter, + LocationFilter, ) from .swapper import load_model from .utils import get_upgrader_schema_for_device -from .widgets import FirmwareSchemaWidget +from .widgets import FirmwareSchemaWidget, MassUpgradeSelect2Widget logger = logging.getLogger(__name__) BatchUpgradeOperation = load_model("BatchUpgradeOperation") @@ -44,6 +48,9 @@ Build = load_model("Build") Device = swapper.load_model("config", "Device") DeviceConnection = swapper.load_model("connection", "DeviceConnection") +Organization = swapper.load_model("openwisp_users", "Organization") +Location = swapper.load_model("geo", "Location") +DeviceGroup = swapper.load_model("config", "DeviceGroup") class BaseAdmin(MultitenantAdminMixin, TimeReadonlyAdminMixin, admin.ModelAdmin): @@ -98,18 +105,61 @@ class BatchUpgradeConfirmationForm(forms.ModelForm): build = forms.ModelChoiceField( widget=forms.HiddenInput(), required=False, queryset=Build.objects.all() ) + group = forms.ModelChoiceField( + queryset=DeviceGroup.objects.none(), + required=False, + help_text=_("Limit the upgrade to devices belonging to this group"), + widget=MassUpgradeSelect2Widget(placeholder=_("Select a group")), + ) + location = forms.ModelChoiceField( + queryset=Location.objects.none(), + required=False, + help_text=_("Limit the upgrade to devices at this location"), + widget=MassUpgradeSelect2Widget(placeholder=_("Select a location")), + ) class Meta: model = BatchUpgradeOperation - fields = ("build", "upgrade_options") + fields = ("build", "group", "location", "upgrade_options") + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) + build = self.initial.get("build") + device_group_qs = DeviceGroup.objects + location_qs = Location.objects + organization_id = None + if build: + organization_id = build.category.organization_id + if organization_id: + device_group_qs = device_group_qs.filter(organization_id=organization_id) + location_qs = location_qs.filter(organization_id=organization_id) + if not self.user.is_superuser: + device_group_qs = device_group_qs.filter( + organization_id__in=self.user.organizations_managed + ) + location_qs = location_qs.filter( + organization_id__in=self.user.organizations_managed + ) + self.fields["group"].queryset = device_group_qs + self.fields["location"].queryset = location_qs - @property - def media(self): + class Media: + # We don't need to include any select2 JS/CSS files as they are + # included by the JSONSchemaWidget used for upgrade_options. js = [ + "admin/js/jquery.init.js", "firmware-upgrader/js/upgrade-selected-confirmation.js", + "firmware-upgrader/js/mass-upgrade-select2.js", ] - css = {"all": ["firmware-upgrader/css/upgrade-selected-confirmation.css"]} - return super().media + forms.Media(js=js, css=css) + css = { + "all": [ + "admin/css/forms.css", + "admin/css/autocomplete.css", + "admin/css/ow-auto-filter.css", + "firmware-upgrader/css/upgrade-selected-confirmation.css", + ] + } @admin.register(load_model("Build")) @@ -156,31 +206,59 @@ def upgrade_selected(self, request, queryset): upgrade_all = request.POST.get("upgrade_all") upgrade_related = request.POST.get("upgrade_related") upgrade_options = request.POST.get("upgrade_options") - form = BatchUpgradeConfirmationForm() + group_id = request.POST.get("group") + location_id = request.POST.get("location") build = queryset.first() + form = BatchUpgradeConfirmationForm(initial={"build": build}, user=request.user) # upgrade has been confirmed if upgrade_all or upgrade_related: form = BatchUpgradeConfirmationForm( - data={"upgrade_options": upgrade_options, "build": build} + data={ + "upgrade_options": upgrade_options, + "build": build, + "group": group_id, + "location": location_id, + }, + user=request.user, ) form.full_clean() if not form.errors: upgrade_options = form.cleaned_data["upgrade_options"] - batch = build.batch_upgrade( - firmwareless=upgrade_all, upgrade_options=upgrade_options - ) - text = _( - "You can track the progress of this mass upgrade operation " - "in this page. Refresh the page from time to time to check " - "its progress." - ) - self.message_user(request, mark_safe(text), messages.SUCCESS) - url = reverse( - f"admin:{app_label}_batchupgradeoperation_change", args=[batch.pk] - ) - return redirect(url) - # upgrade needs to be confirmed - result = BatchUpgradeOperation.dry_run(build=build) + group = form.cleaned_data.get("group") + location = form.cleaned_data.get("location") + try: + batch = build.batch_upgrade( + firmwareless=upgrade_all, + upgrade_options=upgrade_options, + group=group, + location=location, + ) + # Success message for when batch upgrade starts successfully + text = _( + "You can track the progress of this mass upgrade operation " + "in this page." + ) + self.message_user(request, mark_safe(text), messages.SUCCESS) + url = reverse( + f"admin:{app_label}_batchupgradeoperation_change", + args=[batch.pk], + ) + return redirect(url) + except ValidationError as e: + self.message_user( + request, str(e.messages[0] if e.messages else e), messages.ERROR + ) + dry_run_kwargs = { + "build": build, + } + if form.is_bound: + group = form.cleaned_data.get("group") if not form.errors else None + location = form.cleaned_data.get("location") if not form.errors else None + dry_run_kwargs["group"] = group + dry_run_kwargs["location"] = location + result = BatchUpgradeOperation.dry_run( + **dry_run_kwargs, + ) related_device_fw = result["device_firmwares"] firmwareless_devices = result["devices"] title = _("Confirm mass upgrade operation") @@ -189,7 +267,6 @@ def upgrade_selected(self, request, queryset): related_device_fw=related_device_fw, firmwareless_devices=firmwareless_devices, ) - context.update( { "title": title, @@ -201,10 +278,14 @@ def upgrade_selected(self, request, queryset): "firmware_upgrader_schema": json.dumps( upgrader_schema, cls=DjangoJSONEncoder ), + "upgrade_operation_path": reverse( + f"admin:{app_label}_upgradeoperation_change", + args=["00000000-0000-0000-0000-000000000000"], + ), "build": build, "opts": opts, "action_checkbox_name": ACTION_CHECKBOX_NAME, - "media": self.media, + "media": self.media + form.media, } ) request.current_app = self.admin_site.name @@ -219,21 +300,20 @@ def upgrade_selected(self, request, queryset): context, ) - upgrade_selected.short_description = ( - "Mass-upgrade devices related " "to the selected build" - ) - - def change_view(self, request, object_id, form_url="", extra_context=None): + def change_view(self, request, object_id, extra_context=None, **kwargs): app_label = self.model._meta.app_label extra_context = extra_context or {} upgrade_url = f"{app_label}_build_changelist" extra_context.update({"upgrade_url": upgrade_url}) - return super().change_view(request, object_id, form_url, extra_context) + extra_context["django_locale"] = get_language() + return super().change_view( + request, object_id, extra_context=extra_context, **kwargs + ) class UpgradeOperationForm(forms.ModelForm): class Meta: - fields = ["device", "image", "status", "log", "modified"] + fields = ["image", "status", "log", "modified"] labels = {"modified": _("last updated")} @@ -254,6 +334,10 @@ class Media: class ReadonlyUpgradeOptionsMixin: + + class Media: + css = {"all": ["firmware-upgrader/css/upgrade-options.css"]} + @admin.display(description=_("Upgrade options")) def readonly_upgrade_options(self, obj): upgrader_schema = obj.upgrader_schema @@ -265,12 +349,78 @@ def readonly_upgrade_options(self, obj): option_title = value["title"] icon_url = static(f"admin/img/icon-{option_used}.svg") options.append( - f'
  • {option_used}{option_title}
  • ' + format_html( + '
  • {}{}
  • ', + icon_url, + _(option_used), + option_title, + ) ) return format_html( - mark_safe(f'
      {"".join(options)}
    ') + '
      {}
    ', + mark_safe("".join(options)), + ) + + +@admin.register(UpgradeOperation) +class UpgradeOperationAdmin(ReadonlyUpgradeOptionsMixin, ReadOnlyAdmin, BaseAdmin): + form = UpgradeOperationForm + list_display = ["device", "status", "image", "modified"] + list_filter = ["status"] + search_fields = ["device__name"] + readonly_fields = ["device", "image", "status", "log", "modified"] + ordering = ["-modified"] + fields = [ + "device", + "image", + "status", + "log", + "readonly_upgrade_options", + "modified", + ] + change_form_template = "admin/firmware_upgrader/upgrade_operation_change_form.html" + + def _should_display_batch(self, obj, fields): + return ( + obj + and hasattr(obj, "batch") + and obj.batch is not None + and "batch" not in fields ) + def get_readonly_fields(self, request, obj=None): + # Since "readonly_upgrade_options" is dynamically added, we need to + # override get_readonly_fields to include it. + fields = super().get_readonly_fields(request, obj).copy() + if "readonly_upgrade_options" not in fields: + fields.append("readonly_upgrade_options") + if self._should_display_batch(obj, fields): + fields.append("batch") + return fields + + def change_view(self, request, object_id, extra_context=None, **kwargs): + extra_context = extra_context or {} + extra_context["upgrade_operation_cancel_url"] = reverse( + "upgrader:api_upgradeoperation_cancel", + args=["00000000-0000-0000-0000-000000000000"], + ) + extra_context["django_locale"] = get_language() + return super().change_view( + request, object_id, extra_context=extra_context, **kwargs + ) + + def get_fields(self, request, obj=None): + fields = super().get_fields(request, obj).copy() + if self._should_display_batch(obj, fields): + fields.insert(1, "batch") + return fields + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + @admin.register(BatchUpgradeOperation) class BatchUpgradeOperationAdmin(ReadonlyUpgradeOptionsMixin, ReadOnlyAdmin, BaseAdmin): @@ -279,40 +429,172 @@ class BatchUpgradeOperationAdmin(ReadonlyUpgradeOptionsMixin, ReadOnlyAdmin, Bas BuildCategoryOrganizationFilter, "status", BuildCategoryFilter, + BuildFilter, + GroupFilter, + LocationFilter, + "created", ] - list_select_related = ["build__category__organization"] + list_select_related = ["build__category__organization", "group", "location"] ordering = ["-created"] - inlines = [UpgradeOperationInline] multitenant_parent = "build__category" fields = [ "build", + "group", + "location", "status", "completed", "success_rate", "failed_rate", "aborted_rate", + "cancelled_rate", "readonly_upgrade_options", "created", "modified", ] - autocomplete_fields = ["build"] + autocomplete_fields = ["build", "group", "location"] readonly_fields = [ "completed", "success_rate", "failed_rate", "aborted_rate", + "cancelled_rate", "readonly_upgrade_options", ] + change_form_template = ( + "admin/firmware_upgrader/batch_upgrade_operation_change_form.html" + ) + device_upgrades_per_page = 20 - def organization(self, obj): - return obj.build.category.organization + def get_upgrade_operations(self, request, obj): + qs = obj.upgradeoperation_set.select_related("device", "image") + if request.user.is_superuser: + return qs + return qs.filter(device__organization_id__in=request.user.organizations_managed) - organization.short_description = _("organization") + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context["title"] = _("Mass upgrade operations") + return super().changelist_view(request, extra_context) + + def _build_filter_specs(self, request, obj, current_status, current_org): + """Return a list of filter spec objects for the change view. + + ``upgrades_qs`` is not strictly required here but could be passed if + future filters need to inspect the queryset. For now the filters are + based on request parameters and the build's organization. + """ + filter_specs = [] + # Status filter + status_choices = [] + # build a base QueryDict with all current GET params + params = request.GET.copy() + + # generic choice builder used by both status and organization filters + def _make_choice(current_value, display, param_name, value): + # start with a fresh copy so we don't mutate params + q = params.copy() + # always remove existing key for this filter + q.pop(param_name, None) + if value: + q[param_name] = value + qs = q.urlencode() + query_string = f"?{qs}" if qs else "" + return { + "display": display, + "selected": current_value == value, + "query_string": query_string, + } + + for status_value, display_name in ( + ("", _("All")), + ) + UpgradeOperation.STATUS_CHOICES: + status_choices.append( + _make_choice(current_status, display_name, "status", status_value) + ) + + class StatusFilter: + title = _("status") + choices = status_choices + + filter_specs.append(StatusFilter()) + # Organization filter (only for shared builds) + if obj.build.category.organization is None: + org_choices = [] + # "All" choice is selected when there is no current_org value + org_choices.append(_make_choice(current_org, _("All"), "organization", "")) + org_qs = Organization.objects + if not request.user.is_superuser: + org_qs = org_qs.filter(id__in=request.user.organizations_managed) + for org in org_qs.order_by("name"): + org_choices.append( + _make_choice(current_org, org.name, "organization", str(org.id)) + ) + + class OrganizationFilter: + title = _("organization") + choices = org_choices + + filter_specs.append(OrganizationFilter()) + return filter_specs + + def _paginate_operations(self, upgrades_qs, page_param, per_page=None): + """Paginate ``upgrades_qs`` returning (page_obj, paginator, object_list).""" + per_page = per_page or self.device_upgrades_per_page + paginator = Paginator(upgrades_qs.order_by("id"), per_page) + page_number = page_param or 1 + try: + page_obj = paginator.page(page_number) + except InvalidPage: + page_obj = paginator.page(1) + return page_obj, paginator, page_obj.object_list + + def change_view(self, request, object_id, form_url="", extra_context=None): + extra_context = extra_context or {} + obj = self.get_object(request, object_id) + if obj: + upgrades_qs = self.get_upgrade_operations(request, obj) + search_query = request.GET.get("q", "") + if search_query: + upgrades_qs = upgrades_qs.filter(device__name__icontains=search_query) + # Get current filter values + current_status = request.GET.get("status", "") + current_org = request.GET.get("organization", "") + # apply filters to queryset + if current_status: + upgrades_qs = upgrades_qs.filter(status=current_status) + if current_org: + upgrades_qs = upgrades_qs.filter(device__organization_id=current_org) + # build filter specs and paginate results + filter_specs = self._build_filter_specs( + request, obj, current_status, current_org + ) + page_obj, paginator, upgrade_operations = self._paginate_operations( + upgrades_qs, request.GET.get("page", 1) + ) + upgrade_operation_app_label = UpgradeOperation._meta.app_label + extra_context.update( + { + "upgrade_operations": upgrade_operations, + "page_obj": page_obj, + "paginator": paginator, + "filter_specs": filter_specs, + "has_active_filters": any( + request.GET.get(param) for param in ["status", "organization"] + ), + "upgrade_operation_app_label": upgrade_operation_app_label, + } + ) + return super().change_view(request, object_id, extra_context=extra_context) - def get_readonly_fields(self, request, obj): + def get_readonly_fields(self, request, obj=None): fields = super().get_readonly_fields(request, obj) return fields + self.__class__.readonly_fields + def organization(self, obj): + return obj.build.category.organization + + organization.short_description = _("organization") + def completed(self, obj): return obj.progress_report @@ -325,15 +607,19 @@ def failed_rate(self, obj): def aborted_rate(self, obj): return self.__get_rate(obj.aborted_rate) + def cancelled_rate(self, obj): + return self.__get_rate(obj.cancelled_rate) + def __get_rate(self, value): if value: return f"{value}%" - return "N/A" + return _("N/A") completed.short_description = _("completed") success_rate.short_description = _("success rate") failed_rate.short_description = _("failure rate") aborted_rate.short_description = _("abortion rate") + cancelled_rate.short_description = _("cancellation rate") class DeviceFirmwareForm(forms.ModelForm): @@ -415,6 +701,18 @@ class DeviceFirmwareInline( # https://github.com/theatlantic/django-nested-admin/issues/128#issuecomment-665833142 sortable_options = {"disabled": True} + class Media: + js = [ + "connection/js/lib/reconnecting-websocket.min.js", + "firmware-upgrader/js/upgrade-utils.js", + "firmware-upgrader/js/upgrade-progress.js", + ] + css = { + "all": [ + "firmware-upgrader/css/upgrade-progress.css", + ] + } + def _get_conditional_queryset(self, request, obj, select_related=False): return bool(obj) @@ -459,6 +757,19 @@ class DeviceUpgradeOperationInline(ReadonlyUpgradeOptionsMixin, UpgradeOperation ] readonly_fields = fields + class Media: + js = [ + "connection/js/lib/reconnecting-websocket.min.js", + "firmware-upgrader/js/upgrade-utils.js", + "firmware-upgrader/js/upgrade-progress.js", + ] + css = { + "all": [ + "firmware-upgrader/css/upgrade-progress.css", + "firmware-upgrader/css/upgrade-options.css", + ] + } + def get_queryset(self, request, select_related=True): """ Return recent upgrade operations for this device diff --git a/openwisp_firmware_upgrader/api/serializers.py b/openwisp_firmware_upgrader/api/serializers.py index 112648d35..49d5eacbb 100644 --- a/openwisp_firmware_upgrader/api/serializers.py +++ b/openwisp_firmware_upgrader/api/serializers.py @@ -16,6 +16,8 @@ UpgradeOperation = load_model("UpgradeOperation") DeviceFirmware = load_model("DeviceFirmware") Device = swapper.load_model("config", "Device") +DeviceGroup = swapper.load_model("config", "DeviceGroup") +Location = swapper.load_model("geo", "Location") class BaseMeta: @@ -81,16 +83,37 @@ class Meta(BaseMeta): fields = "__all__" +class BatchUpgradeSerializer(FilterSerializerByOrgManaged, serializers.ModelSerializer): + upgrade_all = serializers.BooleanField(required=False, default=False) + + class Meta: + fields = ("upgrade_all", "group", "location") + model = BatchUpgradeOperation + extra_kwargs = { + "group": {"required": False, "allow_null": True}, + "location": {"required": False, "allow_null": True}, + } + + class UpgradeOperationSerializer(serializers.ModelSerializer): class Meta: model = UpgradeOperation - fields = ("id", "device", "image", "status", "log", "modified", "created") + fields = ( + "id", + "device", + "image", + "status", + "log", + "progress", + "modified", + "created", + ) class DeviceUpgradeOperationSerializer(serializers.ModelSerializer): class Meta: model = UpgradeOperation - fields = ("id", "device", "image", "status", "log", "modified") + fields = ("id", "device", "image", "status", "log", "progress", "modified") class BatchUpgradeOperationListSerializer(BaseSerializer): @@ -106,6 +129,7 @@ class BatchUpgradeOperationSerializer(BatchUpgradeOperationListSerializer): success_rate = serializers.IntegerField(read_only=True) failed_rate = serializers.IntegerField(read_only=True) aborted_rate = serializers.IntegerField(read_only=True) + cancelled_rate = serializers.IntegerField(read_only=True) upgradeoperations = UpgradeOperationSerializer( read_only=True, source="upgradeoperation_set", many=True ) diff --git a/openwisp_firmware_upgrader/api/urls.py b/openwisp_firmware_upgrader/api/urls.py index b86f661b7..291f2a8fb 100644 --- a/openwisp_firmware_upgrader/api/urls.py +++ b/openwisp_firmware_upgrader/api/urls.py @@ -57,6 +57,11 @@ views.upgrade_operation_detail, name="api_upgradeoperation_detail", ), + path( + "upgrade-operation//cancel/", + views.upgrade_operation_cancel, + name="api_upgradeoperation_cancel", + ), path( "device//upgrade-operation/", views.device_upgrade_operation_list, diff --git a/openwisp_firmware_upgrader/api/views.py b/openwisp_firmware_upgrader/api/views.py index 2818fe6cf..5e45f9708 100644 --- a/openwisp_firmware_upgrader/api/views.py +++ b/openwisp_firmware_upgrader/api/views.py @@ -1,7 +1,12 @@ +import logging + import swapper from django.core.exceptions import ValidationError from django.http import Http404 +from django.utils.translation import gettext_lazy as _ from django_filters.rest_framework import DjangoFilterBackend +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema from rest_framework import filters, generics, pagination, serializers, status from rest_framework.exceptions import NotFound, PermissionDenied from rest_framework.request import clone_request @@ -9,7 +14,7 @@ from rest_framework.utils.serializer_helpers import ReturnDict from openwisp_firmware_upgrader import private_storage -from openwisp_users.api.mixins import FilterByOrganizationManaged +from openwisp_users.api.mixins import FilterByOrganizationManaged, IsOrganizationManager from openwisp_users.api.mixins import ProtectedAPIMixin as BaseProtectedAPIMixin from openwisp_users.api.permissions import DjangoModelPermissions @@ -18,6 +23,7 @@ from .serializers import ( BatchUpgradeOperationListSerializer, BatchUpgradeOperationSerializer, + BatchUpgradeSerializer, BuildSerializer, CategorySerializer, DeviceFirmwareSerializer, @@ -26,6 +32,8 @@ UpgradeOperationSerializer, ) +logger = logging.getLogger(__name__) + BatchUpgradeOperation = load_model("BatchUpgradeOperation") UpgradeOperation = load_model("UpgradeOperation") Build = load_model("Build") @@ -78,7 +86,7 @@ class BuildDetailView(ProtectedAPIMixin, generics.RetrieveUpdateDestroyAPIView): class BuildBatchUpgradeView(ProtectedAPIMixin, generics.GenericAPIView): model = Build queryset = Build.objects.all().select_related("category") - serializer_class = serializers.Serializer + serializer_class = BatchUpgradeSerializer lookup_fields = ["pk"] organization_field = "category__organization" @@ -86,9 +94,22 @@ def post(self, request, pk): """ Upgrades all the devices related to the specified build ID. """ - upgrade_all = request.POST.get("upgrade_all") is not None instance = self.get_object() - batch = instance.batch_upgrade(firmwareless=upgrade_all) + serializer = self.get_serializer( + data=request.data, + ) + serializer.is_valid(raise_exception=True) + upgrade_all = serializer.validated_data.get("upgrade_all", False) + group = serializer.validated_data.get("group") + location = serializer.validated_data.get("location") + try: + batch = instance.batch_upgrade( + firmwareless=upgrade_all, group=group, location=location + ) + except ValidationError as e: + return Response( + {"error": str(e.messages[0])}, status=status.HTTP_400_BAD_REQUEST + ) return Response({"batch": str(batch.pk)}, status=201) def get(self, request, pk): @@ -97,7 +118,13 @@ def get(self, request, pk): which would be upgraded if POST is used. """ self.instance = self.get_object() - data = BatchUpgradeOperation.dry_run(build=self.instance) + serializer = self.get_serializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + group = serializer.validated_data.get("group") + location = serializer.validated_data.get("location") + data = BatchUpgradeOperation.dry_run( + build=self.instance, group=group, location=location + ) data["device_firmwares"] = [ str(device_fw.pk) for device_fw in data["device_firmwares"] ] @@ -128,7 +155,7 @@ class BatchUpgradeOperationListView(ProtectedAPIMixin, generics.ListAPIView): serializer_class = BatchUpgradeOperationListSerializer organization_field = "build__category__organization" filter_backends = [filters.OrderingFilter, DjangoFilterBackend] - filterset_fields = ["build", "status"] + filterset_fields = ["build", "status", "created"] ordering_fields = ["created", "modified"] ordering = ["-created"] @@ -344,6 +371,86 @@ def get_object_or_none(self): raise +class UpgradeOperationCancelPermission(DjangoModelPermissions): + perms_map = { + **DjangoModelPermissions.perms_map, + "POST": ["%(app_label)s.change_%(model_name)s"], + } + + +class UpgradeOperationCancelView(ProtectedAPIMixin, generics.GenericAPIView): + queryset = UpgradeOperation.objects.all() + serializer_class = serializers.Serializer + permission_classes = ( + IsOrganizationManager, + UpgradeOperationCancelPermission, + ) + lookup_field = "pk" + organization_field = "device__organization" + + @swagger_auto_schema( + operation_description=_("Cancel an upgrade operation"), + operation_summary=_("Cancel upgrade operation"), + responses={ + 200: openapi.Response( + description=_("Upgrade operation cancelled successfully"), + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "message": openapi.Schema( + type=openapi.TYPE_STRING, description=_("Success message") + ) + }, + ), + ), + 409: openapi.Response( + description=_("Operation cannot be cancelled"), + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "error": openapi.Schema( + type=openapi.TYPE_STRING, + description=_( + "Error message explaining why cancellation is not allowed" + ), + ) + }, + ), + ), + }, + ) + def post(self, request, pk): + """Cancel an upgrade operation if conditions are met.""" + try: + operation = self.get_object() + except Http404: + return self._error_response( + "Upgrade operation not found", status.HTTP_404_NOT_FOUND + ) + try: + operation.cancel() + except ValueError as e: + return self._error_response(str(e), status.HTTP_409_CONFLICT) + except Exception: + logger.exception("Failed to cancel upgrade operation %s", pk) + return self._error_response( + "Failed to cancel upgrade operation", + status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + else: + logger.info( + f"Upgrade operation {pk} cancelled successfully by user {request.user}" + ) + return Response( + {"message": "Upgrade operation cancelled successfully"}, + status=status.HTTP_200_OK, + ) + + def _error_response(self, message, status_code): + """Helper method to create consistent error responses.""" + return Response({"error": message}, status=status_code) + + build_list = BuildListView.as_view() build_detail = BuildDetailView.as_view() api_batch_upgrade = BuildBatchUpgradeView.as_view() @@ -358,3 +465,4 @@ def get_object_or_none(self): upgrade_operation_detail = UpgradeOperationDetailView.as_view() device_upgrade_operation_list = DeviceUpgradeOperationListView.as_view() device_firmware_detail = DeviceFirmwareDetailView.as_view() +upgrade_operation_cancel = UpgradeOperationCancelView.as_view() diff --git a/openwisp_firmware_upgrader/apps.py b/openwisp_firmware_upgrader/apps.py index a48238e83..ce76c7187 100644 --- a/openwisp_firmware_upgrader/apps.py +++ b/openwisp_firmware_upgrader/apps.py @@ -7,6 +7,7 @@ from openwisp_utils.utils import default_or_test from . import settings as app_settings +from .websockets import BatchUpgradeProgressPublisher, UpgradeProgressPublisher class FirmwareUpdaterConfig(ApiAppConfig): @@ -26,6 +27,7 @@ def ready(self, *args, **kwargs): super().ready(*args, **kwargs) self.register_menu_groups() self.connect_device_signals() + self.connect_upgrade_signals() self.connect_delete_signals() def register_menu_groups(self): @@ -61,6 +63,7 @@ def connect_device_signals(self): DeviceConnection = load_model("connection", "DeviceConnection") DeviceFirmware = load_model("firmware_upgrader", "DeviceFirmware") FirmwareImage = load_model("firmware_upgrader", "FirmwareImage") + post_save.connect( DeviceFirmware.auto_add_device_firmware_to_device, sender=DeviceConnection, @@ -72,6 +75,21 @@ def connect_device_signals(self): dispatch_uid="firmware_image.auto_add_device_firmwares", ) + def connect_upgrade_signals(self): + UpgradeOperation = load_model("firmware_upgrader", "UpgradeOperation") + BatchUpgradeOperation = load_model("firmware_upgrader", "BatchUpgradeOperation") + + post_save.connect( + UpgradeProgressPublisher.handle_upgrade_operation_post_save, + sender=UpgradeOperation, + dispatch_uid="upgrade_operation.websocket_publish", + ) + post_save.connect( + BatchUpgradeProgressPublisher.handle_batch_upgrade_operation_saved, + sender=BatchUpgradeOperation, + dispatch_uid="batch_upgrade_operation.websocket_publish", + ) + def connect_delete_signals(self): """ Connect signals for handling firmware file deletion diff --git a/openwisp_firmware_upgrader/base/models.py b/openwisp_firmware_upgrader/base/models.py index defd32261..1ffa42f16 100644 --- a/openwisp_firmware_upgrader/base/models.py +++ b/openwisp_firmware_upgrader/base/models.py @@ -6,6 +6,7 @@ import jsonschema import swapper from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.validators import MaxValueValidator from django.db import models, transaction from django.db.models import Q from django.utils import timezone @@ -23,6 +24,7 @@ ReconnectionFailed, RecoverableFailure, UpgradeAborted, + UpgradeCancelled, UpgradeNotNeeded, ) from ..hardware import ( @@ -30,6 +32,7 @@ FIRMWARE_IMAGE_TYPE_CHOICES, REVERSE_FIRMWARE_IMAGE_MAP, ) +from ..signals import firmware_upgrader_log_updated from ..swapper import get_model_name, load_model from ..tasks import ( batch_upgrade_operation, @@ -38,12 +41,15 @@ upgrade_firmware, ) from ..utils import ( + UpgradeProgress, get_upgrader_class_for_device, get_upgrader_class_from_device_connection, get_upgrader_schema_for_device, ) logger = logging.getLogger(__name__) +PROGRESS_MIN = 0 +PROGRESS_MAX = 100 class UpgradeOptionsMixin(models.Model): @@ -155,10 +161,27 @@ def clean(self): } ) - def batch_upgrade(self, firmwareless, upgrade_options=None): + def batch_upgrade( + self, firmwareless, upgrade_options=None, group=None, location=None + ): upgrade_options = upgrade_options or {} + # Check if there are any devices to upgrade with the given filters + dry_run_result = load_model("BatchUpgradeOperation").dry_run( + build=self, group=group, location=location + ) + # If no devices match the filters, don't start the upgrade + if not ( + dry_run_result["device_firmwares"].exists() + or (firmwareless and dry_run_result["devices"].exists()) + ): + raise ValidationError( + _( + "No devices found matching the specified filters. " + "Please adjust your group and/or location filters." + ) + ) batch = load_model("BatchUpgradeOperation")( - build=self, upgrade_options=upgrade_options + build=self, upgrade_options=upgrade_options, group=group, location=location ) batch.full_clean() batch.save() @@ -167,7 +190,9 @@ def batch_upgrade(self, firmwareless, upgrade_options=None): ) return batch - def _find_related_device_firmwares(self, select_devices=False): + def _find_related_device_firmwares( + self, select_devices=False, group=None, location=None + ): """ Returns all the DeviceFirmware objects related to the firmware category of this build that have not been installed yet @@ -175,7 +200,7 @@ def _find_related_device_firmwares(self, select_devices=False): related = ["image"] if select_devices: related.append("device") - return ( + qs = ( load_model("DeviceFirmware") .objects.all() .select_related(*related) @@ -183,8 +208,13 @@ def _find_related_device_firmwares(self, select_devices=False): .exclude(image__build=self, installed=True) .order_by("-created") ) + if group: + qs = qs.filter(device__group=group) + if location: + qs = qs.filter(device__devicelocation__location=location) + return qs - def _find_firmwareless_devices(self, boards=None): + def _find_firmwareless_devices(self, boards=None, group=None, location=None): """ Returns devices which have no related DeviceFirmware but that are upgradable to one of the image of this build @@ -200,6 +230,10 @@ def _find_firmwareless_devices(self, boards=None): ) if self.category.organization_id: qs = qs.filter(organization_id=self.category.organization_id) + if group: + qs = qs.filter(group=group) + if location: + qs = qs.filter(devicelocation__location=location) return qs.order_by("-created") @@ -491,11 +525,26 @@ def get_image_queryset_for_device(cls, device, device_firmware=None): class AbstractBatchUpgradeOperation(UpgradeOptionsMixin, TimeStampedEditableModel): build = models.ForeignKey(get_model_name("Build"), on_delete=models.CASCADE) + group = models.ForeignKey( + swapper.get_model_name("config", "DeviceGroup"), + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("device group"), + ) + location = models.ForeignKey( + swapper.get_model_name("geo", "Location"), + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("location"), + ) STATUS_CHOICES = ( ("idle", _("idle")), ("in-progress", _("in progress")), ("success", _("completed successfully")), ("failed", _("completed with some failures")), + ("cancelled", _("completed with some cancellations")), ) status = models.CharField( max_length=12, choices=STATUS_CHOICES, default=STATUS_CHOICES[0][0] @@ -509,16 +558,34 @@ class Meta: def __str__(self): return f"Upgrade of {self.build} on {self.created}" - def update(self): - operations = self.upgradeoperation_set - if operations.filter(status="in-progress").exists(): - return - # if there's any failed operation, mark as failure - if operations.filter(status="failed").exists(): - self.status = "failed" - else: - self.status = "success" - self.save() + def clean(self): + super().clean() + if ( + self.group + and self.build.category.organization + and self.group.organization != self.build.category.organization + ): + raise ValidationError( + { + "group": _( + "The organization of the group doesn't match " + "the organization of the build category" + ) + } + ) + if ( + self.location + and self.build.category.organization + and self.location.organization != self.build.category.organization + ): + raise ValidationError( + { + "location": _( + "The organization of the location doesn't match " + "the organization of the build category" + ) + } + ) def upgrade(self, firmwareless): self.status = "in-progress" @@ -528,9 +595,13 @@ def upgrade(self, firmwareless): self.upgrade_firmwareless_devices() @staticmethod - def dry_run(build): - related_device_fw = build._find_related_device_firmwares(select_devices=True) - firmwareless_devices = build._find_firmwareless_devices() + def dry_run(build, group=None, location=None): + related_device_fw = build._find_related_device_firmwares( + select_devices=True, group=group, location=location + ) + firmwareless_devices = build._find_firmwareless_devices( + group=group, location=location + ) return { "device_firmwares": related_device_fw, "devices": firmwareless_devices, @@ -541,7 +612,9 @@ def upgrade_related_devices(self): upgrades all devices which have an existing related DeviceFirmware """ - device_firmwares = self.build._find_related_device_firmwares() + device_firmwares = self.build._find_related_device_firmwares( + group=self.group, location=self.location + ) for device_fw in device_firmwares: image = self.build.firmwareimage_set.filter( type=device_fw.image.type @@ -560,7 +633,9 @@ def upgrade_firmwareless_devices(self): # for each image, find related "firmwareless" # devices and perform upgrade one by one for image in self.build.firmwareimage_set.all(): - devices = self.build._find_firmwareless_devices(image.boards) + devices = self.build._find_firmwareless_devices( + image.boards, group=self.group, location=self.location + ) for device in devices: DeviceFirmware = load_model("DeviceFirmware") device_fw = DeviceFirmware(device=device, image=image) @@ -601,6 +676,13 @@ def aborted_rate(self): aborted = self.upgrade_operations.filter(status="aborted").count() return self.__get_rate(aborted) + @property + def cancelled_rate(self): + if not self.total_operations: + return 0 + cancelled = self.upgrade_operations.filter(status="cancelled").count() + return self.__get_rate(cancelled) + @property def upgrader_class(self): return self._get_upgrader_class() @@ -634,13 +716,90 @@ def __get_rate(self, number): result = Decimal(number) / Decimal(self.total_operations) * 100 return round(result, 2) + def calculate_and_update_status(self): + """ + Calculate batch status based on operation statuses and update if changed. + This method consolidates all business logic for determining batch status. + Returns tuple of (status, stats_dict) for WebSocket publishing. + + Status determination rules: + - 'in-progress': If any operation is still in progress + - 'cancelled': If completed and any operation was cancelled + - 'failed': If completed and any operation failed or aborted + - 'success': If all operations completed successfully + - Otherwise: Maintain current status + """ + operations = self.upgradeoperation_set + stats = operations.aggregate( + total_operations=models.Count("id"), + in_progress=models.Count( + models.Case( + models.When(status="in-progress", then=1), + output_field=models.IntegerField(), + ) + ), + completed=models.Count( + models.Case( + models.When(~models.Q(status="in-progress"), then=1), + output_field=models.IntegerField(), + ) + ), + successful=models.Count( + models.Case( + models.When(status="success", then=1), + output_field=models.IntegerField(), + ) + ), + failed=models.Count( + models.Case( + models.When(status="failed", then=1), + output_field=models.IntegerField(), + ) + ), + cancelled=models.Count( + models.Case( + models.When(status="cancelled", then=1), + output_field=models.IntegerField(), + ) + ), + aborted=models.Count( + models.Case( + models.When(status="aborted", then=1), + output_field=models.IntegerField(), + ) + ), + ) + # Determine overall batch status based on individual operation statuses + if stats["in_progress"] > 0: + new_status = "in-progress" + elif stats["failed"] > 0 or stats["aborted"] > 0: + new_status = "failed" + elif stats["cancelled"] > 0: + new_status = "cancelled" + elif ( + stats["successful"] > 0 + and stats["completed"] == stats["total_operations"] + and stats["total_operations"] > 0 + ): + new_status = "success" + else: + new_status = self.status + # Update status only if it has changed + if self.status != new_status: + self.status = new_status + self.save(update_fields=["status"]) + return new_status, stats + class AbstractUpgradeOperation(UpgradeOptionsMixin, TimeStampedEditableModel): + + _CANCELLABLE_STATUS = "in-progress" STATUS_CHOICES = ( ("in-progress", _("in progress")), ("success", _("success")), - ("failed", _("failed")), - ("aborted", _("aborted")), + ("failed", _("failed")), # failed at late stage or can't reconnect + ("cancelled", _("cancelled")), # cancelled by the user + ("aborted", _("aborted")), # aborted due to prerequisites not met ) device = models.ForeignKey( swapper.get_model_name("config", "Device"), on_delete=models.CASCADE @@ -652,6 +811,12 @@ class AbstractUpgradeOperation(UpgradeOptionsMixin, TimeStampedEditableModel): max_length=12, choices=STATUS_CHOICES, default=STATUS_CHOICES[0][0] ) log = models.TextField(blank=True) + progress = models.PositiveSmallIntegerField( + default=PROGRESS_MIN, + validators=[ + MaxValueValidator(PROGRESS_MAX), + ], + ) batch = models.ForeignKey( get_model_name("BatchUpgradeOperation"), on_delete=models.CASCADE, @@ -670,6 +835,60 @@ def log_line(self, line, save=True): logger.info(f"# {line}") if save: self.save() + firmware_upgrader_log_updated.send( + sender=self.__class__, instance=self, line=line + ) + + def update_progress(self, progress, save=True): + """Update progress with validation.""" + if not isinstance(progress, (int, float)): + raise ValidationError( + _("Progress must be numeric, got %(progress_type)s") + % {"progress_type": type(progress)} + ) + if not PROGRESS_MIN <= progress <= PROGRESS_MAX: + raise ValidationError( + _("Progress must be between %(min)s-%(max)s, got %(progress)s") + % {"min": PROGRESS_MIN, "max": PROGRESS_MAX, "progress": progress} + ) + self.progress = int(progress) + if save: + self.save() + + def cancel(self): + """Cancels the upgrade operation if conditions are met, atomically.""" + with transaction.atomic(): + # A concurrent upgrade worker can change status/progress + # between fetch and save(), so cancellation can succeed + # or fail incorrectly. + # By using an UPDATE query, we avoid such situation. + updated = self._meta.model.objects.filter( + pk=self.pk, + status=self._CANCELLABLE_STATUS, + progress__lt=UpgradeProgress.CANCELLATION_THRESHOLD, + ).update(status="cancelled") + if not updated: + # The cancellation did not succeed, check why + self.refresh_from_db(fields=["status", "progress"]) + if self.status != self._CANCELLABLE_STATUS: + raise ValueError( + _("Cannot cancel operation with status: %(status)s") + % {"status": self.status} + ) + if self.progress >= UpgradeProgress.CANCELLATION_THRESHOLD: + raise ValueError( + _( + "Cannot cancel upgrade: firmware reflashing has already started" + ) + ) + raise ValueError(_("Unknown error during cancellation")) + # Since we use update() to change the status, we need to refresh + # the instance for 2 reasons: + # 1. get the updated status + # 2. get any log ling which may have been written + # concurrently in background workers, so we avoid overwriting + self.refresh_from_db() + self.log_line(_("Upgrade operation has been cancelled by user")) def _recoverable_failure_handler(self, recoverable, error): cause = str(error) @@ -681,6 +900,9 @@ def _recoverable_failure_handler(self, recoverable, error): self.log_line(f"Max retries exceeded. Upgrade failed: {cause}.", save=False) def upgrade(self, recoverable=True): + # Do not run if operation is not in-progress (eg: cancelled, aborted, success, failed) + if self.status != "in-progress": + return DeviceConnection = swapper.load_model("connection", "DeviceConnection") try: conn = DeviceConnection.get_working_connection(self.device) @@ -690,12 +912,13 @@ def upgrade(self, recoverable=True): return log_template = ( - "Failed to connect with device using {credentials}." + "Failed to connect with {device} using {credentials}." " Error: {failure_reason}" ) for conn in self.device.deviceconnection_set.select_related("credentials"): self.log_line( log_template.format( + device=self.device.name, credentials=conn.credentials, failure_reason=conn.failure_reason, ), @@ -720,8 +943,8 @@ def upgrade(self, recoverable=True): .objects.filter(device=self.device, status="in-progress") .exclude(pk=self.pk) ) - if qs.count() > 0: - message = "Another upgrade operation is in progress, aborting..." + if qs.exists(): + message = _("Another upgrade operation is in progress, aborting...") logger.warning(message) self.log_line(message, save=False) self.status = "aborted" @@ -738,11 +961,15 @@ def upgrade(self, recoverable=True): # means the device was aleady flashed previously with the same image except UpgradeNotNeeded: self.status = "success" + self.update_progress(100, save=False) installed = True # this exception is raised when the test of the image fails, # meaning the image file is corrupted or not apt for flashing except UpgradeAborted: self.status = "aborted" + # this exception is raised when the upgrade is cancelled by the user + except UpgradeCancelled: + self.status = "cancelled" # raising this exception will cause celery to retry again # the upgrade according to its configuration except RecoverableFailure as e: @@ -767,6 +994,7 @@ def upgrade(self, recoverable=True): else: installed = True self.status = "success" + self.update_progress(100, save=False) self.save() # if the firmware has been successfully installed, # or if it was already installed @@ -776,12 +1004,11 @@ def upgrade(self, recoverable=True): self.device.devicefirmware.save(upgrade=False) def save(self, *args, **kwargs): - result = super().save(*args, **kwargs) + super().save(*args, **kwargs) # when an operation is completed # trigger an update on the batch operation if self.batch and self.status != "in-progress": - self.batch.update() - return result + self.batch.calculate_and_update_status() @property def upgrader_schema(self): diff --git a/openwisp_firmware_upgrader/exceptions.py b/openwisp_firmware_upgrader/exceptions.py index c473feb92..3c30d6aa0 100644 --- a/openwisp_firmware_upgrader/exceptions.py +++ b/openwisp_firmware_upgrader/exceptions.py @@ -18,6 +18,12 @@ class UpgradeAborted(FirmwareUpgraderException): """ +class UpgradeCancelled(FirmwareUpgraderException): + """ + Raised when the upgrade has been cancelled by the user + """ + + class ReconnectionFailed(FirmwareUpgraderException): """ Raised when the reconnection after the upgrade fails diff --git a/openwisp_firmware_upgrader/filters.py b/openwisp_firmware_upgrader/filters.py index 935341e5b..2fd6040ef 100644 --- a/openwisp_firmware_upgrader/filters.py +++ b/openwisp_firmware_upgrader/filters.py @@ -29,6 +29,24 @@ class BuildCategoryFilter(MultitenantRelatedOrgFilter): rel_model = Build +class BuildFilter(MultitenantRelatedOrgFilter): + field_name = "build" + parameter_name = "build_id" + title = _("build") + + +class LocationFilter(MultitenantRelatedOrgFilter): + field_name = "location" + parameter_name = "location_id" + title = _("location") + + +class GroupFilter(MultitenantRelatedOrgFilter): + field_name = "group" + parameter_name = "group_id" + title = _("group") + + class BuildCategoryOrganizationFilter(MultitenantOrgFilter): parameter_name = "build__category__organization" rel_model = Category diff --git a/openwisp_firmware_upgrader/migrations/0004_batch_upgrade_operation_idle_status.py b/openwisp_firmware_upgrader/migrations/0004_batch_upgrade_operation_idle_status.py index 7b5e2f01f..fd9e846b7 100644 --- a/openwisp_firmware_upgrader/migrations/0004_batch_upgrade_operation_idle_status.py +++ b/openwisp_firmware_upgrader/migrations/0004_batch_upgrade_operation_idle_status.py @@ -13,12 +13,12 @@ class Migration(migrations.Migration): model_name="batchupgradeoperation", name="status", field=models.CharField( - choices=[ + choices=( ("idle", "idle"), ("in-progress", "in progress"), ("success", "completed successfully"), ("failed", "completed with some failures"), - ], + ), default="idle", max_length=12, ), diff --git a/openwisp_firmware_upgrader/migrations/0013_upgradeoperation_progress.py b/openwisp_firmware_upgrader/migrations/0013_upgradeoperation_progress.py new file mode 100644 index 000000000..4590757de --- /dev/null +++ b/openwisp_firmware_upgrader/migrations/0013_upgradeoperation_progress.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.5 on 2025-08-27 12:44 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("firmware_upgrader", "0012_update_image_type_identifiers"), + ] + + operations = [ + migrations.AddField( + model_name="upgradeoperation", + name="progress", + field=models.PositiveSmallIntegerField( + default=0, + validators=[ + django.core.validators.MaxValueValidator(100), + ], + ), + ), + ] diff --git a/openwisp_firmware_upgrader/migrations/0014_alter_upgradeoperation_status.py b/openwisp_firmware_upgrader/migrations/0014_alter_upgradeoperation_status.py new file mode 100644 index 000000000..d52eec7b5 --- /dev/null +++ b/openwisp_firmware_upgrader/migrations/0014_alter_upgradeoperation_status.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.4 on 2025-07-28 18:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("firmware_upgrader", "0013_upgradeoperation_progress"), + ] + + operations = [ + migrations.AlterField( + model_name="upgradeoperation", + name="status", + field=models.CharField( + choices=[ + ("in-progress", "in progress"), + ("success", "success"), + ("failed", "failed"), + ("cancelled", "cancelled"), + ("aborted", "aborted"), + ], + default="in-progress", + max_length=12, + ), + ), + ] diff --git a/openwisp_firmware_upgrader/migrations/0015_add_group_to_batchupgradeoperation.py b/openwisp_firmware_upgrader/migrations/0015_add_group_to_batchupgradeoperation.py new file mode 100644 index 000000000..1c49a9771 --- /dev/null +++ b/openwisp_firmware_upgrader/migrations/0015_add_group_to_batchupgradeoperation.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.4 on 2025-08-05 13:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("firmware_upgrader", "0014_alter_upgradeoperation_status"), + migrations.swappable_dependency(settings.CONFIG_DEVICEGROUP_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="batchupgradeoperation", + name="group", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.CONFIG_DEVICEGROUP_MODEL, + verbose_name="device group", + ), + ), + ] diff --git a/openwisp_firmware_upgrader/migrations/0016_batchupgradeoperation_location.py b/openwisp_firmware_upgrader/migrations/0016_batchupgradeoperation_location.py new file mode 100644 index 000000000..235bd23b0 --- /dev/null +++ b/openwisp_firmware_upgrader/migrations/0016_batchupgradeoperation_location.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.5 on 2025-09-01 16:09 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("firmware_upgrader", "0015_add_group_to_batchupgradeoperation"), + migrations.swappable_dependency(settings.GEO_LOCATION_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="batchupgradeoperation", + name="location", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.GEO_LOCATION_MODEL, + verbose_name="location", + ), + ), + ] diff --git a/openwisp_firmware_upgrader/migrations/0017_alter_batchupgradeoperation_status.py b/openwisp_firmware_upgrader/migrations/0017_alter_batchupgradeoperation_status.py new file mode 100644 index 000000000..dc8fd24ce --- /dev/null +++ b/openwisp_firmware_upgrader/migrations/0017_alter_batchupgradeoperation_status.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.11 on 2026-02-23 17:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("firmware_upgrader", "0016_batchupgradeoperation_location"), + ] + + operations = [ + migrations.AlterField( + model_name="batchupgradeoperation", + name="status", + field=models.CharField( + choices=[ + ("idle", "idle"), + ("in-progress", "in progress"), + ("success", "completed successfully"), + ("failed", "completed with some failures"), + ("cancelled", "completed with some cancellations"), + ], + default="idle", + max_length=12, + ), + ), + ] diff --git a/openwisp_firmware_upgrader/routing.py b/openwisp_firmware_upgrader/routing.py new file mode 100644 index 000000000..982554539 --- /dev/null +++ b/openwisp_firmware_upgrader/routing.py @@ -0,0 +1,26 @@ +from django.urls import path + +from .websockets import ( + BatchUpgradeProgressConsumer, + DeviceUpgradeProgressConsumer, + UpgradeProgressConsumer, +) + +websocket_urlpatterns = [ + path( + "ws/firmware-upgrader/upgrade-operation//", + UpgradeProgressConsumer.as_asgi(), + ), + path( + "ws/firmware-upgrader/batch-upgrade-operation//", + BatchUpgradeProgressConsumer.as_asgi(), + ), + path( + "ws/firmware-upgrader/device//", + DeviceUpgradeProgressConsumer.as_asgi(), + ), +] + + +def get_routes(): + return websocket_urlpatterns diff --git a/openwisp_firmware_upgrader/signals.py b/openwisp_firmware_upgrader/signals.py new file mode 100644 index 000000000..813390ccb --- /dev/null +++ b/openwisp_firmware_upgrader/signals.py @@ -0,0 +1,3 @@ +from django.dispatch import Signal + +firmware_upgrader_log_updated = Signal() diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/css/batch-upgrade-operation.css b/openwisp_firmware_upgrader/static/firmware-upgrader/css/batch-upgrade-operation.css new file mode 100644 index 000000000..013e941cc --- /dev/null +++ b/openwisp_firmware_upgrader/static/firmware-upgrader/css/batch-upgrade-operation.css @@ -0,0 +1,269 @@ +#batchupgradeoperation_form .submit-row { + display: none; +} + +.upgrade-operations-title { + font-size: 22px; + font-weight: 300; + margin: 0; + padding: 0; +} + +.search-section { + padding: 20px; +} + +.search-form { + display: flex; + align-items: center; +} + +#main #content .search-icon { + width: 20px; + height: 20px; + margin-right: 15px; + font-size: 16px; + margin-top: -3px; +} + +#main #content .search-input { + padding: 10px 15px; +} + +#main #content .search-button { + padding: 10px 20px; + margin-left: 15px; +} + +.filter-clear-link:hover { + color: var(--ow-color-fg-darker); +} + +.results-table { + width: 100%; + border-collapse: collapse; +} + +#main #content .device-link { + color: var(--ow-color-primary); + font-weight: bold; +} + +.empty-results { + padding: 40px; + text-align: center; + color: var(--body-quiet-color); + font-style: italic; +} + +.pagination { + padding: 15px 20px; + text-align: right; + border-top: 2px solid var(--hairline-color); + background: var(--darkened-bg); +} + +.pagination a { + color: var(--body-fg); + text-decoration: none; + margin: 0 5px; +} + +.pagination .current-page { + margin: 0 10px; + color: var(--body-quiet-color); +} + +.paginator { + color: var(--body-quiet-color); + padding: 10px 20px; + border-bottom: 1px solid var(--hairline-color); + margin: 0; +} + +.batch-progress-container { + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 8px; + padding: 20px; + margin: 20px 0; +} +.batch-progress-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.batch-status { + font-weight: 600; + font-size: 16px; + color: var(--body-fg); + text-transform: capitalize; +} + +.batch-progress-text { + color: var(--body-quiet-color); +} + +.batch-progress-bar { + height: 8px; + background-color: var(--hairline-color); + border-radius: 4px; + overflow: hidden; + position: relative; +} + +.status-cell { + width: 200px; + min-width: 200px; +} + +.upgrade-status-container { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; +} + +.upgrade-status-container .upgrade-progress-bar { + width: 100%; + height: 10px; + background-color: var(--hairline-color); + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.upgrade-status-container .upgrade-progress-fill { + height: 100%; + transition: width 0.3s ease; + border-radius: 3px; +} + +.upgrade-status-container .upgrade-progress-fill.in-progress { + background-color: #70bf2b; +} + +.upgrade-status-container .upgrade-progress-fill.success, +.upgrade-status-container .upgrade-progress-fill.completed-successfully { + background-color: #70bf2b; +} + +.upgrade-status-container .upgrade-progress-fill.failed { + background-color: #dc3545; +} + +.upgrade-status-container .upgrade-progress-fill.aborted { + background-color: #6c757d; +} + +.upgrade-status-container .upgrade-progress-fill.cancelled { + background-color: #8b8b8b; +} + +.upgrade-status-container span { + font-size: 12px; + font-weight: 500; +} + +.upgrade-status-container .upgrade-progress-text { + color: var(--body-quiet-color); + margin-top: 2px; +} + +.upgrade-status-in-progress { + color: #70bf2b; +} + +.upgrade-status-success, +.upgrade-status-completed-successfully { + color: #70bf2b; +} + +.upgrade-status-failed { + color: #dc3545; +} + +.upgrade-status-aborted { + color: #6c757d; +} + +.upgrade-status-cancelled { + color: #8b8b8b; +} + +.upgrade-status-idle { + color: #6c757d; +} + +.batch-main-progress { + display: flex; + align-items: center; + gap: 20px; + margin-left: 20px; +} + +.batch-main-progress .upgrade-progress-bar { + width: 300px; + height: 12px; + background-color: #d9d9d9; + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border-color); +} + +.batch-main-progress .upgrade-progress-fill { + height: 100%; + border-radius: 2px; + transition: width 0.5s ease; +} + +.batch-main-progress .upgrade-progress-fill.idle { + background-color: #6c757d; +} + +.batch-main-progress .upgrade-progress-fill.in-progress { + background-color: #70bf2b; +} + +.batch-main-progress .upgrade-progress-fill.success, +.batch-main-progress .upgrade-progress-fill.completed-successfully { + background-color: #70bf2b; +} + +.batch-main-progress .upgrade-progress-fill.failed { + background-color: #dc3545; +} + +.batch-main-progress .upgrade-progress-fill.aborted { + background-color: #6c757d; +} + +.batch-main-progress .upgrade-progress-fill.cancelled { + background-color: #8b8b8b; +} + +.batch-main-progress .upgrade-progress-fill.partial-success { + background-color: #ff9800; +} + +.batch-main-progress .upgrade-progress-text { + color: var(--ow-color-black); + font-weight: bold; + white-space: nowrap; +} + +/* Adjustments for list filters */ +#main #content .left-arrow { + left: -1.125rem; +} +#main #content .right-arrow { + right: -1.125rem; +} +#main #content #ow-changelist-filter { + padding: 1.25rem 0rem; +} +#main #content .filters-top { + margin-bottom: 0.5rem; +} diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-options.css b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-options.css index c43c6ab2c..95cc98ceb 100644 --- a/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-options.css +++ b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-options.css @@ -8,3 +8,6 @@ .readonly-upgrade-options li img { margin-right: 5px; } +#upgradeoperation_form .submit-row { + display: none; +} diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-progress.css b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-progress.css new file mode 100644 index 000000000..9f4bea741 --- /dev/null +++ b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-progress.css @@ -0,0 +1,189 @@ +.field-status .readonly { + min-height: 25px; + display: flex; + align-items: center; +} + +.upgrade-status-container { + display: flex; + align-items: center; + gap: 15px; +} + +.upgrade-progress-bar { + width: 300px; + height: 12px; + background-color: #d9d9d9; + border-radius: 4px; + overflow: hidden; + display: inline-block; + vertical-align: middle; + border: 1px solid var(--border-color); +} + +.upgrade-progress-fill { + height: 100%; + background-color: #70bf2b; + transition: width 0.5s ease; + border-radius: 2px; + position: relative; +} + +.upgrade-progress-fill.success { + background-color: #70bf2b; +} + +.upgrade-progress-fill.failed { + background-color: #dc3545; +} + +.upgrade-progress-fill.aborted { + background-color: #464646; +} + +.upgrade-progress-fill.cancelled { + background-color: #8b8b8b; +} + +.upgrade-progress-fill.in-progress { + background-color: #70bf2b; +} + +.upgrade-progress-text { + color: var(--ow-color-black); + font-weight: bold; + font-size: 12px; + display: inline-block; + vertical-align: middle; +} + +.upgrade-cancel-btn { + background-color: var(--delete-button-bg); + color: var(--button-fg); + border: none; + padding: 8px 18px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + margin-left: 10px; +} + +.upgrade-cancel-btn:hover { + background-color: var(--delete-button-hover-bg); +} + +.upgrade-cancel-btn.disabled { + background-color: var(--secondary); + cursor: not-allowed; + opacity: 0.6; +} + +.ow-hide { + display: none !important; +} + +.ow-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; +} + +.ow-overlay-notification { + background-color: var(--ow-overlay-60); + display: flex; + justify-content: center; + align-items: center; + transition: opacity 0.3s; +} + +.ow-cancel-confirmation-dialog { + position: relative; + background-color: var(--ow-color-white); + padding: 25px; + border-radius: 10px; + box-shadow: 0 5px 15px var(--ow-overlay-40); + width: 100%; + max-width: 600px; + text-align: left; + animation: modal-slide-in 0.3s ease-out; +} + +@keyframes modal-slide-in { + from { + opacity: 0; + transform: translateY(-50px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.ow-cancel-confirmation-header { + display: flex; + align-items: flex-start; + margin-bottom: 20px; +} + +.ow-cancel-confirmation-title { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--body-fg); + line-height: 1.3; + text-transform: uppercase; +} + +.ow-cancel-confirmation-content { + margin-bottom: 25px; +} + +.ow-cancel-confirmation-content p { + margin: 0 0 12px 0; + font-size: 16px; + line-height: 1.5; +} + +.ow-cancel-confirmation-buttons { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 20px; +} + +.ow-cancel-confirmation-buttons .button { + padding: 8px 24px; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; +} + +.ow-cancel-confirmation-buttons .danger-btn { + background-color: var(--delete-button-bg); +} +.ow-cancel-confirmation-buttons .danger-btn:hover, +.ow-cancel-confirmation-buttons .danger-btn:focus { + background-color: var(--delete-button-hover-bg); +} + +.ow-dialog-close-x { + position: absolute; + top: 15px; + right: 20px; + font-size: 24px; + font-weight: bold; + color: var(--body-quiet-color); + cursor: pointer; + line-height: 1; + transition: color 0.2s ease; +} + +.ow-dialog-close-x:hover { + color: var(--body-fg); +} diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-selected-confirmation.css b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-selected-confirmation.css index ff4fcccec..2b5920eb7 100644 --- a/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-selected-confirmation.css +++ b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-selected-confirmation.css @@ -1,15 +1,21 @@ -form > div > div.form-row { - display: none; +#content form .help { + margin-top: 3px; } -form .errorlist { - margin-top: -10px; -} -.errorlist > li { - visibility: hidden; -} -.errorlist ul li { - visibility: visible; +#content p.intro { + margin-bottom: 20px; } #ow-loading { display: flex; } +.jsoneditor-wrapper label { + width: auto; +} +.jsoneditor-wrapper fieldset { + margin-bottom: 0; +} +input.special { + margin: 0 0 0 10px; +} +.select2-input + .select2 .select2-selection { + min-width: auto !important; +} diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/js/batch-upgrade-progress.js b/openwisp_firmware_upgrader/static/firmware-upgrader/js/batch-upgrade-progress.js new file mode 100644 index 000000000..13bc19646 --- /dev/null +++ b/openwisp_firmware_upgrader/static/firmware-upgrader/js/batch-upgrade-progress.js @@ -0,0 +1,387 @@ +"use strict"; + +django.jQuery(function ($) { + const batchUpgradeId = getBatchUpgradeIdFromUrl(); + window.batchUpgradeId = batchUpgradeId; + if (!batchUpgradeId) { + return; + } + initializeExistingBatchUpgradeOperations($); + initializeMainProgressBar($); + const wsHost = getFirmwareUpgraderApiHost(); + if (!wsHost) { + // helper already printed message; skip websocket setup + return; + } + + const wsUrl = `${getWebSocketProtocol()}${wsHost}/ws/firmware-upgrader/batch-upgrade-operation/${batchUpgradeId}/`; + + const batchUpgradeProgressWebSocket = new ReconnectingWebSocket(wsUrl, null, { + automaticOpen: false, + timeoutInterval: 7000, + maxReconnectAttempts: 10, + reconnectInterval: 30000, + reconnectDecay: 2, + }); + window.batchUpgradeProgressWebSocket = batchUpgradeProgressWebSocket; + // Initialize websocket connection + initBatchUpgradeProgressWebSockets($, batchUpgradeProgressWebSocket); +}); + +let batchUpgradeOperationsInitialized = false; + +function requestCurrentBatchState(websocket) { + if (websocket.readyState === WebSocket.OPEN) { + try { + const requestMessage = { + type: "request_current_state", + batch_id: window.batchUpgradeId, + }; + websocket.send(JSON.stringify(requestMessage)); + } catch (error) { + console.error("Error requesting current batch state:", error); + } + } +} + +function initializeExistingBatchUpgradeOperations($, isRetry = false) { + if (batchUpgradeOperationsInitialized && isRetry) { + return; + } + let statusCells = $("#result_list tbody td.status-cell"); + let processedCount = 0; + statusCells.each(function () { + let statusCell = $(this); + if (statusCell.find(".upgrade-status-container").length > 0) { + return; + } + let operationStatus = statusCell.attr("data-operation-status"); + if (operationStatus && FW_STATUS_HELPERS.isValid(operationStatus)) { + let operationId = statusCell.attr("data-operation-id") || "unknown"; + let operation = { + status: operationStatus, + id: operationId, + progress: null, + }; + renderOperationProgressBarInCell(statusCell, operation); + processedCount++; + } + }); + + if (processedCount > 0 || isRetry) { + batchUpgradeOperationsInitialized = true; + } else if (!isRetry) { + setTimeout(function () { + initializeExistingBatchUpgradeOperations($, true); + }, 1000); + } +} + +function initBatchUpgradeProgressWebSockets($, batchUpgradeProgressWebSocket) { + batchUpgradeProgressWebSocket.addEventListener("open", function (e) { + let existingContainers = $( + "#result_list tbody td.status-cell .upgrade-status-container", + ); + if (existingContainers.length === 0) { + batchUpgradeOperationsInitialized = false; + requestCurrentBatchState(batchUpgradeProgressWebSocket); + initializeExistingBatchUpgradeOperations($, false); + } else { + // Just request current state without reinitializing + requestCurrentBatchState(batchUpgradeProgressWebSocket); + } + }); + + batchUpgradeProgressWebSocket.addEventListener("close", function (e) { + batchUpgradeOperationsInitialized = false; + if (e.code === 1006) { + console.error("WebSocket closed"); + } + }); + + batchUpgradeProgressWebSocket.addEventListener("error", function (e) { + console.error("WebSocket error occurred", e); + }); + + batchUpgradeProgressWebSocket.addEventListener("message", function (e) { + try { + let data = JSON.parse(e.data); + if (data.type === "batch_state") { + updateBatchProgress(data.batch_status); + if (data.operations && Array.isArray(data.operations)) { + data.operations.forEach(function (operation) { + updateBatchOperationProgress({ + operation_id: operation.id, + status: operation.status, + progress: operation.progress, + modified: operation.modified, + }); + }); + } + } else if (data.type === "batch_status") { + updateBatchProgress(data); + } else if (data.type === "operation_progress") { + updateBatchOperationProgress(data); + } else if (data.type === "operation_update") { + updateBatchOperationProgress({ + operation_id: data.operation.id, + status: data.operation.status, + progress: data.operation.progress, + modified: data.operation.modified, + }); + } + } catch (error) { + console.error("Error parsing WebSocket message:", error); + } + }); + batchUpgradeProgressWebSocket.open(); +} +function updateBatchProgress(data) { + let $ = django.jQuery; + let mainProgressElement = $(".batch-main-progress"); + if (mainProgressElement.length > 0) { + let progressPercentage = + data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0; + let showPercentageText = true; + let statusClass = FW_UPGRADE_CSS_CLASSES.IN_PROGRESS; // Safe default + + if (data.status === FW_UPGRADE_STATUS.SUCCESS) { + progressPercentage = 100; + statusClass = FW_UPGRADE_CSS_CLASSES.COMPLETED_SUCCESSFULLY; + showPercentageText = true; + } else if (data.status === FW_UPGRADE_STATUS.CANCELLED) { + progressPercentage = 100; + statusClass = FW_UPGRADE_CSS_CLASSES.CANCELLED; + showPercentageText = false; + } else if (data.status === FW_UPGRADE_STATUS.FAILED) { + let successfulOpsCount = $("#result_list tbody tr").filter(function () { + let statusText = $(this).find(".status-cell .status-content").text().trim(); + return FW_STATUS_GROUPS.SUCCESS.has(statusText); + }).length; + // Also check individual operation containers for success + if (successfulOpsCount === 0) { + $("#result_list tbody tr").each(function () { + let statusContainer = $(this).find(".upgrade-status-container"); + if ( + statusContainer.length && + statusContainer.find(".upgrade-progress-fill.success").length + ) { + successfulOpsCount++; + } + }); + } + if (successfulOpsCount > 0) { + // Some operations succeeded - partial success (orange) + progressPercentage = 100; + statusClass = FW_UPGRADE_CSS_CLASSES.PARTIAL_SUCCESS; + showPercentageText = false; + } else { + // All operations failed - total failure (red) + progressPercentage = 100; + statusClass = FW_UPGRADE_CSS_CLASSES.FAILED; + showPercentageText = false; + } + } + let progressHtml = ` +
    +
    +
    +
    + `; + if (showPercentageText) { + progressHtml += ` + ${escapeHtml(String(progressPercentage))}% + `; + } + mainProgressElement.html(progressHtml); + } + // Update completion information in the admin form if available + if (data.total !== undefined && data.completed !== undefined) { + let completedInfo = $(".field-completed .readonly"); + if (completedInfo.length > 0) { + completedInfo.text(`${data.completed} out of ${data.total}`); + } + } + + let statusField = $(".field-status .readonly"); + if (statusField.length > 0 && data.status) { + let displayStatus = data.status; + if (data.status === FW_UPGRADE_STATUS.SUCCESS) { + displayStatus = FW_UPGRADE_DISPLAY_STATUS.COMPLETED_SUCCESSFULLY; + } else if (data.status === FW_UPGRADE_STATUS.CANCELLED) { + displayStatus = FW_UPGRADE_DISPLAY_STATUS.COMPLETED_WITH_CANCELLATIONS; + } else if (data.status === FW_UPGRADE_STATUS.FAILED) { + displayStatus = FW_UPGRADE_DISPLAY_STATUS.COMPLETED_WITH_FAILURES; + } else if (data.status === FW_UPGRADE_STATUS.IN_PROGRESS) { + displayStatus = FW_UPGRADE_DISPLAY_STATUS.IN_PROGRESS; + } + let progressBar = statusField.find(".batch-main-progress"); + let statusText = statusField + .contents() + .not(progressBar) + .filter(function () { + return this.nodeType === 3 && this.textContent.trim(); + }) + .first(); + if (statusText.length > 0) { + statusText[0].textContent = displayStatus; + } else { + progressBar.before(document.createTextNode(displayStatus)); + } + } +} + +function updateBatchOperationProgress(data) { + let $ = django.jQuery; + let found = false; + $("#result_list tbody tr").each(function () { + let row = $(this); + let statusCell = row.find("td.status-cell"); + let operationId = statusCell.attr("data-operation-id"); + + if (operationId === data.operation_id) { + found = true; + let operation = { + status: data.status, + id: data.operation_id, + progress: data.progress, + }; + renderOperationProgressBarInCell(statusCell, operation); + if (data.modified) { + let modifiedCell = row.find("td:nth-child(4)"); + modifiedCell.text(getFormattedDateTimeString(data.modified)); + } + } + }); + if (!found) { + addNewOperationRow(data); + } +} + +function addNewOperationRow(data) { + let $ = django.jQuery; + if (!data.device_name || !data.device_id) { + return; + } + let tbody = $("#result_list tbody"); + tbody.find("tr td[colspan]").parent().remove(); + let existingRows = tbody.find("tr").length; + let rowClass = existingRows % 2 === 0 ? "row1" : "row2"; + let deviceUrl = owDeviceUpgradeOperationUrl.replace( + "00000000-0000-0000-0000-000000000000", + data.operation_id, + ); + let imageDisplay = data.image_name || gettext("None"); + let modifiedTime = data.modified + ? getFormattedDateTimeString(data.modified) + : gettext("Just now"); + // Build row using DOM attributes to prevent XSS vulnerability due to string interpolation + let $row = $("").addClass(rowClass); + let $deviceTd = $(""); + let $link = $("") + .addClass("device-link") + .attr("href", deviceUrl) + .attr("aria-label", gettext("View upgrade operation for") + " " + data.device_name) + .text(data.device_name); + $deviceTd.append($link); + let $statusTd = $("") + .addClass("status-cell") + .attr("data-operation-id", data.operation_id); + let $statusContent = $("
    ").addClass("status-content").text(data.status); // SAFE + $statusTd.append($statusContent); + let $imageTd = $("").text(imageDisplay); + let $modifiedTd = $("").text(modifiedTime); + $row.append($deviceTd, $statusTd, $imageTd, $modifiedTd); + tbody.append($row); + + let operation = { + status: data.status, + id: data.operation_id, + progress: data.progress, + }; + renderOperationProgressBarInCell($statusTd, operation); +} + +function renderOperationProgressBarInCell(statusCell, operation) { + // Renders a visual progress bar in the given status cell based on the + // operation's status and progress. + let $ = django.jQuery; + let status = operation.status; + let progressPercentage = normalizeProgress(operation.progress, status); + statusCell.empty(); + statusCell.append('
    '); + let statusContainer = statusCell.find(".upgrade-status-container"); + let statusClass = STATUS_TO_CSS_CLASS[status] || ""; + if (STATUSES_WITH_FULL_PROGRESS.has(status)) { + progressPercentage = 100; + } + // Per operation bars do not show percentage text to keep table rows compact + let progressHtml = renderProgressBarHtml(progressPercentage, statusClass, false); + statusContainer.html(progressHtml); +} + +function getBatchUpgradeIdFromUrl() { + try { + let matches = window.location.pathname.match(/\/batchupgradeoperation\/([^\/]+)\//); + return matches && matches[1] ? matches[1] : null; + } catch (error) { + console.error("Error extracting batch ID from URL:", error); + return null; + } +} + +function initializeMainProgressBar($) { + let statusField = $(".field-status .readonly"); + if (statusField.length > 0) { + let currentStatusText = statusField + .contents() + .filter(function () { + return this.nodeType === 3 && this.textContent.trim(); + }) + .first() + .text() + .trim(); + let mainProgressElement = $(".batch-main-progress"); + if (mainProgressElement.length > 0 && currentStatusText) { + let progressPercentage = 100; + let statusClass = ""; + let showPercentageText; + + if (currentStatusText === FW_UPGRADE_DISPLAY_STATUS.COMPLETED_SUCCESSFULLY) { + statusClass = FW_UPGRADE_CSS_CLASSES.COMPLETED_SUCCESSFULLY; + showPercentageText = true; + } else if ( + currentStatusText === FW_UPGRADE_DISPLAY_STATUS.COMPLETED_WITH_CANCELLATIONS + ) { + statusClass = FW_UPGRADE_CSS_CLASSES.CANCELLED; + showPercentageText = false; + } else if ( + currentStatusText === FW_UPGRADE_DISPLAY_STATUS.COMPLETED_WITH_FAILURES + ) { + statusClass = FW_UPGRADE_CSS_CLASSES.PARTIAL_SUCCESS; + showPercentageText = false; + } else if (currentStatusText === FW_UPGRADE_DISPLAY_STATUS.IN_PROGRESS) { + statusClass = FW_UPGRADE_CSS_CLASSES.IN_PROGRESS; + showPercentageText = true; + progressPercentage = 0; + } else { + statusClass = FW_UPGRADE_CSS_CLASSES.FAILED; + showPercentageText = false; + } + let progressHtml = ` +
    +
    +
    +
    + `; + if (showPercentageText) { + progressHtml += ` + ${escapeHtml(progressPercentage)}% + `; + } + mainProgressElement.html(progressHtml); + } + } +} diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/js/mass-upgrade-select2.js b/openwisp_firmware_upgrader/static/firmware-upgrader/js/mass-upgrade-select2.js new file mode 100644 index 000000000..b49866611 --- /dev/null +++ b/openwisp_firmware_upgrader/static/firmware-upgrader/js/mass-upgrade-select2.js @@ -0,0 +1,21 @@ +"use strict"; + +django.jQuery(function ($) { + $(".select2-input").each(function () { + var $element = $(this); + var placeholder = $element.data("placeholder") || gettext("Select an option"); + $element.select2({ + theme: "default", + dropdownCssClass: $element.data("dropdown-css-class"), + placeholder: placeholder, + allowClear: !!$element.data("allow-clear"), + width: "resolve", + minimumInputLength: 0, + language: { + noResults: function () { + return gettext("No results found."); + }, + }, + }); + }); +}); diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-progress.js b/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-progress.js new file mode 100644 index 000000000..614bc5e3b --- /dev/null +++ b/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-progress.js @@ -0,0 +1,472 @@ +"use strict"; + +django.jQuery && + django.jQuery(function ($) { + // Detect page type and get appropriate ID + const pageType = detectPageType(); + const pageId = getPageId(pageType); + if (!pageId) { + return; + } + window.upgradePageType = pageType; + window.upgradePageId = pageId; + + // Initialize based on page type + if (pageType === "device") { + // Device page with multiple operations + initializeExistingMultiUpgrades($); + } else if (pageType === "operation") { + // Single operation page + initializeExistingSingleUpgrade($); + } + + const wsHost = getFirmwareUpgraderApiHost(); + if (!wsHost) { + // error already logged by helper + return; + } + const wsUrl = getWebSocketUrl(pageType, pageId, wsHost); + + const upgradeProgressWebSocket = new ReconnectingWebSocket(wsUrl, null, { + automaticOpen: false, + timeoutInterval: 7000, + maxReconnectAttempts: 5, + reconnectInterval: 3000, + }); + + window.upgradeProgressWebSocket = upgradeProgressWebSocket; + // Initialize websocket connection + initUpgradeProgressWebSockets($, upgradeProgressWebSocket); + }); + +let upgradeOperationsInitialized = false; + +// Store accumulated log content to preserve across WebSocket reconnections +let accumulatedLogContent = new Map(); + +function formatLogForDisplay(logContent) { + return logContent ? escapeHtml(logContent).replace(/\n/g, "
    ") : ""; +} + +function getSanitizedStatusFromField(statusField) { + let statusText = + statusField.find(".upgrade-progress-text").text() || statusField.text().trim(); + statusText = statusText.replace(/\d+%.*$/, "").trim(); + let statusKey = getKeyFromValue(FW_UPGRADE_DISPLAY_STATUS, statusText); + return FW_UPGRADE_STATUS[statusKey]; +} + +function requestCurrentOperationState(websocket) { + if (websocket.readyState === WebSocket.OPEN) { + try { + let requestMessage = { + type: "request_current_state", + }; + // Add appropriate ID based on page type + if (window.upgradePageType === "device") { + requestMessage.device_id = window.upgradePageId; + } else if (window.upgradePageType === "operation") { + requestMessage.operation_id = window.upgradePageId; + } + websocket.send(JSON.stringify(requestMessage)); + } catch (error) { + console.error("Error requesting current state:", error); + } + } +} + +function initializeExistingMultiUpgrades($, isRetry = false) { + if (upgradeOperationsInitialized && isRetry) { + return; + } + let statusFields = $("#upgradeoperation_set-group .field-status .readonly"); + let processedCount = 0; + // loop over all the stauts fields + statusFields.each(function (index) { + let statusField = $(this); + let statusValue = getSanitizedStatusFromField(statusField); + if (statusField.find(".upgrade-status-container").length > 0) { + return; + } + if ( + statusValue && + (FW_STATUS_HELPERS.includesProgress(statusValue) || + ALL_VALID_FW_STATUSES.has(statusValue)) + ) { + let operationFieldset = statusField.closest(".dynamic-upgradeoperation_set"); + let logElement = operationFieldset.find(".field-log .readonly"); + // Get operation ID for restoring accumulated content + let operationIdInput = operationFieldset.find("input[name*='id'][value]"); + let operationId = + operationIdInput.length > 0 ? operationIdInput.val() : "unknown"; + // Use accumulated log content if available + let logContent; + if (accumulatedLogContent.has(operationId)) { + logContent = accumulatedLogContent.get(operationId); + if (logElement.length > 0) { + logElement.html(formatLogForDisplay(logContent)); + } + } else { + logContent = logElement.length > 0 ? logElement.text().trim() : ""; + // Store this initial content for future use + if (logContent && operationId !== "unknown") { + accumulatedLogContent.set(operationId, logContent); + } + } + // Create operation object for updateStatusWithProgressBar + let operation = { + status: statusValue, + log: logContent, + id: operationId, + progress: null, + }; + updateStatusWithProgressBar(statusField, operation); + processedCount++; + } + }); + // Mark as initialized if found and processed some operations, or if this is already a retry + if (processedCount > 0 || isRetry) { + upgradeOperationsInitialized = true; + } else if (!isRetry) { + setTimeout(function () { + initializeExistingMultiUpgrades($, true); + }, 1000); + } +} + +function initializeExistingSingleUpgrade($, isRetry = false) { + if (upgradeOperationsInitialized && isRetry) { + return; + } + let statusField = $(".field-status .readonly"); + let logElement = $(".field-log .readonly"); + if (statusField.find(".upgrade-status-container").length > 0) { + return; + } + let statusValue = getSanitizedStatusFromField(statusField); + if (statusValue) { + let operationId = window.upgradePageId; + let logContent = logElement.length > 0 ? logElement.text().trim() : ""; + if (logContent && operationId) { + accumulatedLogContent.set(operationId, logContent); + } + let operation = { + status: statusValue, + log: logContent, + id: operationId, + progress: null, + }; + updateStatusWithProgressBar(statusField, operation); + upgradeOperationsInitialized = true; + } else if (!isRetry) { + setTimeout(function () { + initializeExistingSingleUpgrade($, true); + }, 1000); + } +} + +function initUpgradeProgressWebSockets($, upgradeProgressWebSocket) { + upgradeProgressWebSocket.addEventListener("open", function (e) { + upgradeOperationsInitialized = false; + requestCurrentOperationState(upgradeProgressWebSocket); + }); + + upgradeProgressWebSocket.addEventListener("close", function (e) { + upgradeOperationsInitialized = false; + + if (e.code === 1006) { + console.error("WebSocket closed"); + } + }); + + upgradeProgressWebSocket.addEventListener("error", function (e) { + console.error("WebSocket error occurred", e); + }); + + upgradeProgressWebSocket.addEventListener("message", function (e) { + try { + let data = JSON.parse(e.data); + // Both device & operation pages receive "operation_update" events. + if (data.type === "operation_update") { + let op = data.operation; + if (op) { + updateUpgradeOperationDisplay(op); + } + } + } catch (error) { + console.error("Error parsing WebSocket message:", error); + } + }); + upgradeProgressWebSocket.open(); +} + +function updateUpgradeOperationDisplay(operation) { + let $ = django.jQuery, + operationFieldset; + + if (window.upgradePageType === "device") { + let operationIdInputField = $(`input[value="${$.escapeSelector(operation.id)}"]`); + if (!operationIdInputField.length) { + return; + } + operationFieldset = operationIdInputField.parent().find("fieldset"); + } else if (window.upgradePageType === "operation") { + operationFieldset = $("#upgradeoperation_form fieldset"); + } + + let statusField = operationFieldset.find(".field-status .readonly"); + if (operation.log && operation.id) { + accumulatedLogContent.set(operation.id, operation.log); + } + // Update status with progress bar + updateStatusWithProgressBar(statusField, operation); + let logElement = operationFieldset.find(".field-log .readonly"); + let shouldScroll = isScrolledToBottom(logElement); + logElement.html(formatLogForDisplay(operation.log)); + if (FW_STATUS_HELPERS.isCompleted(operation.status)) { + accumulatedLogContent.delete(operation.id); + } + // Auto-scroll to bottom if user was already at bottom + if (shouldScroll) { + scrollToBottom(logElement); + } + // Update modified timestamp + if (operation.modified) { + operationFieldset + .find(".field-modified .readonly") + .html(getFormattedDateTimeString(operation.modified)); + } +} + +function updateStatusWithProgressBar(statusField, operation) { + let $ = django.jQuery; + let status = operation.status; + let progressPercentage = getProgressPercentage(status, operation.progress); + let progressClass = status.replace(/\s+/g, "-"); + let statusKey = getKeyFromValue(FW_UPGRADE_STATUS, status); + let statusHtml = ` + + ${FW_UPGRADE_DISPLAY_STATUS[statusKey]} + + `; + if (FW_STATUS_GROUPS.IN_PROGRESS.has(status)) { + statusHtml += ` +
    +
    +
    +
    + + ${escapeHtml(progressPercentage)}% + + `; + const canCancel = progressPercentage < 65; + const cancelButtonClass = canCancel + ? "upgrade-cancel-btn" + : "upgrade-cancel-btn disabled"; + const cancelButtonTitle = canCancel + ? gettext("Cancel upgrade") + : gettext("Cannot cancel - firmware flashing in progress"); + statusHtml += ` + + `; + } else if (FW_STATUS_GROUPS.SUCCESS.has(status)) { + statusHtml += ` +
    +
    +
    +
    + 100% + `; + } else if (FW_STATUS_GROUPS.FAILURE.has(status)) { + statusHtml += ` +
    +
    +
    +
    + `; + } else { + statusHtml += ` +
    +
    +
    +
    + + ${progressPercentage}% + + `; + } + if (!statusField.find(".upgrade-status-container").length) { + statusField.empty(); + statusField.append('
    '); + } + let statusContainer = statusField.find(".upgrade-status-container"); + statusContainer.html(statusHtml); + statusContainer + .find(".upgrade-cancel-btn:not(.disabled)") + .off("click") + .on("click", function (e) { + e.preventDefault(); + const operationId = $(this).data("operation-id"); + showCancelConfirmationModal(operationId); + }); +} + +function getProgressPercentage(status, operationProgress = null) { + if (operationProgress !== null && operationProgress !== undefined) { + return Math.min(100, Math.max(0, operationProgress)); + } + if (status === FW_UPGRADE_STATUS.SUCCESS) { + return 100; + } + return 0; +} + +function detectPageType() { + // Check if it's a single upgrade operation page + if (document.getElementById("upgradeoperation_form")) { + return "operation"; + } + // Check if it's a device page (with upgrade operations) + if (document.getElementById("upgradeoperation_set-group")) { + return "device"; + } + return null; +} + +function getPageId(pageType) { + if (pageType === "operation") { + return getOperationIdFromUrl(); + } else if (pageType === "device") { + return getObjectIdFromUrl(); + } + return null; +} + +function getWebSocketUrl(pageType, pageId, wsHost) { + const protocol = getWebSocketProtocol(); + if (pageType === "operation") { + return `${protocol}${wsHost}/ws/firmware-upgrader/upgrade-operation/${pageId}/`; + } else if (pageType === "device") { + return `${protocol}${wsHost}/ws/firmware-upgrader/device/${pageId}/`; + } + return null; +} + +function getOperationIdFromUrl() { + try { + let matches = window.location.pathname.match(/\/upgradeoperation\/([^\/]+)\//); + return matches && matches[1] ? matches[1] : null; + } catch (error) { + console.error("Error extracting operation ID from URL:", error); + return null; + } +} + +function getObjectIdFromUrl() { + let objectId; + try { + objectId = /\/((\w{4,12}-?)){5}\//.exec(window.location)[0]; + } catch (error) { + try { + objectId = /\/(\d+)\//.exec(window.location)[0]; + } catch (error) { + return null; + } + } + return objectId.replace(/\//g, ""); +} + +function showCancelConfirmationModal(operationId) { + const $ = django.jQuery; + // Create modal if it doesn't exist + if ($("#ow-cancel-confirmation-modal").length === 0) { + createCancelConfirmationModal($); + } + // Set the operation ID and show the modal + $("#ow-cancel-confirmation-modal").data("operation-id", operationId); + $("#ow-cancel-confirmation-modal").removeClass("ow-hide"); +} + +function createCancelConfirmationModal($) { + const modalHtml = ` +
    +
    + × +
    +

    ${gettext("Stop upgrade operation")}

    +
    +
    +

    ${gettext("Are you sure you want to cancel this upgrade operation?")}

    +
    +
    + + +
    +
    +
    + `; + $("body").append(modalHtml); + // Close modal handlers + $("#ow-cancel-confirmation-modal .ow-dialog-close").on("click", function () { + $("#ow-cancel-confirmation-modal").addClass("ow-hide"); + }); + // Confirm cancellation handler + $("#ow-cancel-confirmation-modal .ow-cancel-btn-confirm").on("click", function () { + const operationId = $("#ow-cancel-confirmation-modal").data("operation-id"); + $("#ow-cancel-confirmation-modal").addClass("ow-hide"); + cancelUpgradeOperation(operationId); + }); + // Close on escape key + $(document).on("keyup", function (e) { + if (e.keyCode === 27 && $("#ow-cancel-confirmation-modal").is(":visible")) { + $("#ow-cancel-confirmation-modal").addClass("ow-hide"); + } + }); + // Close on overlay click (outside dialog) + $("#ow-cancel-confirmation-modal").on("click", function (e) { + if (e.target === this) { + $(this).addClass("ow-hide"); + } + }); +} + +function cancelUpgradeOperation(operationId) { + const $ = django.jQuery; + // Show loading overlay + $("#ow-loading").show(); + $.ajax({ + url: owUpgradeOperationCancelUrl.replace( + "00000000-0000-0000-0000-000000000000", + operationId, + ), + type: "POST", + headers: { + "X-CSRFToken": $('input[name="csrfmiddlewaretoken"]').val(), + }, + xhrFields: { + withCredentials: true, + }, + crossDomain: true, + success: function (response) { + $("#ow-loading").hide(); + }, + error: function (xhr, status, error) { + $("#ow-loading").hide(); + let errorMessage = gettext("Failed to cancel upgrade operation."); + alert(errorMessage); + }, + }); +} diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-selected-confirmation.js b/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-selected-confirmation.js index e9a47ce61..37b1b672e 100644 --- a/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-selected-confirmation.js +++ b/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-selected-confirmation.js @@ -1,7 +1,8 @@ "use strict"; django.jQuery(function ($) { - if (firmwareUpgraderSchema === null) { + const upgradeOptions = $('textarea[name="upgrade_options"]'); + if (firmwareUpgraderSchema === null || !upgradeOptions.length) { $(".form-row").hide(); } else { django._loadJsonSchemaUi( diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-utils.js b/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-utils.js new file mode 100644 index 000000000..054891275 --- /dev/null +++ b/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-utils.js @@ -0,0 +1,218 @@ +"use strict"; + +// Core firmware upgrade statuses +const FW_UPGRADE_STATUS = { + SUCCESS: "success", + FAILED: "failed", + ABORTED: "aborted", + CANCELLED: "cancelled", + IN_PROGRESS: "in-progress", +}; + +// Display statuses +const FW_UPGRADE_DISPLAY_STATUS = { + SUCCESS: gettext("success"), + FAILED: gettext("failed"), + ABORTED: gettext("aborted"), + CANCELLED: gettext("cancelled"), + IN_PROGRESS: gettext("in progress"), + COMPLETED_SUCCESSFULLY: gettext("completed successfully"), + COMPLETED_WITH_FAILURES: gettext("completed with some failures"), + COMPLETED_WITH_CANCELLATIONS: gettext("completed with some cancellations"), +}; + +// CSS class names +const FW_UPGRADE_CSS_CLASSES = { + COMPLETED_SUCCESSFULLY: "completed-successfully", + PARTIAL_SUCCESS: "partial-success", + CANCELLED: "cancelled", + FAILED: "failed", + IN_PROGRESS: "in-progress", + SUCCESS: "success", + ABORTED: "aborted", +}; + +const VALID_FW_STATUSES = new Set(Object.values(FW_UPGRADE_STATUS)); + +// For rendering checks (from HTML display values): include both backend and display statuses +const ALL_VALID_FW_STATUSES = new Set([ + ...Object.values(FW_UPGRADE_STATUS), + ...Object.values(FW_UPGRADE_DISPLAY_STATUS), +]); + +// Status groups for easier conditional checking +const FW_STATUS_GROUPS = { + COMPLETED: new Set([ + FW_UPGRADE_STATUS.SUCCESS, + FW_UPGRADE_STATUS.FAILED, + FW_UPGRADE_STATUS.ABORTED, + FW_UPGRADE_STATUS.CANCELLED, + FW_UPGRADE_DISPLAY_STATUS.COMPLETED_SUCCESSFULLY, + FW_UPGRADE_DISPLAY_STATUS.COMPLETED_WITH_FAILURES, + FW_UPGRADE_DISPLAY_STATUS.COMPLETED_WITH_CANCELLATIONS, + ]), + + IN_PROGRESS: new Set([ + FW_UPGRADE_STATUS.IN_PROGRESS, + FW_UPGRADE_DISPLAY_STATUS.IN_PROGRESS, + ]), + + SUCCESS: new Set([ + FW_UPGRADE_STATUS.SUCCESS, + FW_UPGRADE_DISPLAY_STATUS.COMPLETED_SUCCESSFULLY, + ]), + + FAILURE: new Set([ + FW_UPGRADE_STATUS.FAILED, + FW_UPGRADE_STATUS.ABORTED, + FW_UPGRADE_STATUS.CANCELLED, + ]), +}; + +const FW_STATUS_HELPERS = { + isValid: (status) => ALL_VALID_FW_STATUSES.has(status), + isCompleted: (status) => FW_STATUS_GROUPS.COMPLETED.has(status), + isInProgress: (status) => FW_STATUS_GROUPS.IN_PROGRESS.has(status), + isSuccess: (status) => FW_STATUS_GROUPS.SUCCESS.has(status), + isFailure: (status) => FW_STATUS_GROUPS.FAILURE.has(status), + includesProgress: (status) => status && status.includes("progress"), +}; + +// Mapping of status to CSS class for progress bars +const STATUS_TO_CSS_CLASS = { + [FW_UPGRADE_STATUS.IN_PROGRESS]: FW_UPGRADE_CSS_CLASSES.IN_PROGRESS, + [FW_UPGRADE_STATUS.SUCCESS]: FW_UPGRADE_CSS_CLASSES.SUCCESS, + [FW_UPGRADE_STATUS.FAILED]: FW_UPGRADE_CSS_CLASSES.FAILED, + [FW_UPGRADE_STATUS.ABORTED]: FW_UPGRADE_CSS_CLASSES.ABORTED, + [FW_UPGRADE_STATUS.CANCELLED]: FW_UPGRADE_CSS_CLASSES.CANCELLED, +}; + +// Statuses that should show 100% progress +const STATUSES_WITH_FULL_PROGRESS = new Set([ + FW_UPGRADE_STATUS.SUCCESS, + FW_UPGRADE_STATUS.FAILED, + FW_UPGRADE_STATUS.ABORTED, + FW_UPGRADE_STATUS.CANCELLED, +]); + +// returns the object key of statusMap corresponding to value +function getKeyFromValue(statusMap, value) { + return Object.keys(statusMap).find((key) => statusMap[key] === value); +} + +// Normalize numeric progress input and fallback to sensible defaults. +function normalizeProgress(operationProgress = null, status) { + if (operationProgress !== null && operationProgress !== undefined) { + let parsed = parseInt(operationProgress, 10); + if (isNaN(parsed)) { + return 0; + } + return Math.min(100, Math.max(0, parsed)); + } + if (FW_STATUS_HELPERS.isCompleted(status)) { + return 100; + } + return 0; +} + +// Return progress bar HTML fragment given percentage and CSS class. +function renderProgressBarHtml( + progressPercentage, + statusClass, + showPercentageText = true, +) { + return ` +
    +
    +
    + ${showPercentageText ? `${progressPercentage}%` : ""} + `; +} + +function getWebSocketProtocol() { + let protocol = "ws://"; + if (window.location.protocol === "https:") { + protocol = "wss://"; + } + return protocol; +} + +/** + * HTML-escape a string to prevent insertion of untrusted content into DOM + * fragments. + */ +function escapeHtml(unsafe) { + if (unsafe === null || unsafe === undefined) { + return ""; + } + unsafe = String(unsafe); + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Return the host portion of the firmware upgrader API host configured + * by the server. When the global `owFirmwareUpgraderApiHost` object is + * missing or does not contain a `host` property we log an error and return + * null so callers can bail out gracefully. + */ +function getFirmwareUpgraderApiHost() { + if ( + typeof owFirmwareUpgraderApiHost === "undefined" || + !owFirmwareUpgraderApiHost.host + ) { + console.error("owFirmwareUpgraderApiHost is not defined or missing host property"); + return null; + } + return owFirmwareUpgraderApiHost.host; +} + +function getFormattedDateTimeString(dateTimeString) { + let dateTime = new Date(dateTimeString); + let locale = window.djangoLocale || "en-us"; + return dateTime.toLocaleString(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function isScrolledToBottom(element) { + if (!element || !element.length) return false; + let el = element[0]; + return el.scrollHeight - el.clientHeight <= el.scrollTop + 1; +} + +function scrollToBottom(element) { + if (element && element.length) { + let el = element[0]; + el.scrollTop = el.scrollHeight - el.clientHeight; + } +} + +if (typeof window !== "undefined") { + window.FW_UPGRADE_STATUS = FW_UPGRADE_STATUS; + window.FW_UPGRADE_DISPLAY_STATUS = FW_UPGRADE_DISPLAY_STATUS; + window.FW_UPGRADE_CSS_CLASSES = FW_UPGRADE_CSS_CLASSES; + window.VALID_FW_STATUSES = VALID_FW_STATUSES; + window.VALID_FW_DISPLAY_STATUSES = new Set(Object.values(FW_UPGRADE_DISPLAY_STATUS)); + window.ALL_VALID_FW_STATUSES = ALL_VALID_FW_STATUSES; + window.FW_STATUS_GROUPS = FW_STATUS_GROUPS; + window.FW_STATUS_HELPERS = FW_STATUS_HELPERS; + window.STATUS_TO_CSS_CLASS = STATUS_TO_CSS_CLASS; + window.STATUSES_WITH_FULL_PROGRESS = STATUSES_WITH_FULL_PROGRESS; + window.normalizeProgress = normalizeProgress; + window.renderProgressBarHtml = renderProgressBarHtml; + window.getWebSocketProtocol = getWebSocketProtocol; + window.escapeHtml = escapeHtml; + window.getFirmwareUpgraderApiHost = getFirmwareUpgraderApiHost; + window.getFormattedDateTimeString = getFormattedDateTimeString; + window.isScrolledToBottom = isScrolledToBottom; + window.scrollToBottom = scrollToBottom; +} diff --git a/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/batch_upgrade_operation_change_form.html b/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/batch_upgrade_operation_change_form.html new file mode 100644 index 000000000..263164330 --- /dev/null +++ b/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/batch_upgrade_operation_change_form.html @@ -0,0 +1,193 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static admin_list ow_tags %} + +{% block extrahead %} +{{ block.super }} + + + + +{% endblock %} + +{% block content %} +{{ block.super }} + + +

    {% trans "Upgrade Operations" %}

    + + +{% if filter_specs %} +
    +{% endif %} + + +
    +
    + + + + + + + {% for param, value in request.GET.items %} + {% if param != 'q' and param != 'page' %} + + {% endif %} + {% endfor %} +
    +
    + + +
    + + + + + + + + + + + {% for operation in upgrade_operations %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    {% trans "Device" %}{% trans "Status" %}{% trans "Image" %}{% trans "Last Updated" %}
    + + {{ operation.device.name }} + + +
    {{ operation.get_status_display }}
    +
    + {% if operation.image %} + {{ operation.image }} + {% else %} + {% trans "None" %} + {% endif %} + {{ operation.modified|date }}
    {% trans "No device upgrades found." %}
    + + + {% if paginator %} +

    + {% blocktrans count counter=paginator.count %} + {{ counter }} upgrade operation + {% plural %}{{ counter }} upgrade operations + {% endblocktrans %} +

    + {% endif %} + + + {% if page_obj.has_other_pages %} + + {% endif %} +
    +{% endblock %} + +{% block footer %} +{{ block.super }} + + + + + + +{% endblock %} diff --git a/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/device_firmware_inline.html b/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/device_firmware_inline.html index 0b740ea3d..04db5c2e3 100644 --- a/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/device_firmware_inline.html +++ b/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/device_firmware_inline.html @@ -2,4 +2,8 @@ diff --git a/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/upgrade_operation_change_form.html b/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/upgrade_operation_change_form.html new file mode 100644 index 000000000..0fb5bdec7 --- /dev/null +++ b/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/upgrade_operation_change_form.html @@ -0,0 +1,22 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} +{{ block.super }} + +{% endblock extrahead %} + +{% block footer %} +{{ block.super }} + + + + +{% endblock footer %} diff --git a/openwisp_firmware_upgrader/templates/admin/upgrade_selected_confirmation.html b/openwisp_firmware_upgrader/templates/admin/upgrade_selected_confirmation.html index 31aef52d3..c0ad01d9f 100644 --- a/openwisp_firmware_upgrader/templates/admin/upgrade_selected_confirmation.html +++ b/openwisp_firmware_upgrader/templates/admin/upgrade_selected_confirmation.html @@ -2,74 +2,105 @@ {% load i18n l10n admin_urls static %} {% block extrahead %} - {{ block.super }} - {{ media }} - + {{ block.super }} + {{ media }} + {% endblock %} {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation delete-selected-confirmation{% endblock %} {% block breadcrumbs %} - + {% endblock %} {% block content %} {% if related_count or firmwareless_count %} -

    {% blocktrans %}Are you sure you want to proceed with this batch upgrade operation?{% endblocktrans %}

    +

    + {% blocktrans %} + Are you sure you want to proceed with the + mass upgrade operation for {{ build }}? + {% endblocktrans %} +

    {% elif not related_count and not firmwareless_count %} -

    {% blocktrans %}No devices to upgrade were found. - Hint: Maybe they have been all already updated previously.{% endblocktrans %}

    - {% endif %} - {% if related_count %} -

    {% trans "Devices related to build" %} {{ build }} ({{ related_count }})

    -
      - {% for device_fw in related_device_fw %} -
    • {{ device_fw.device.name }}
    • - {% endfor %} -
    - {% endif %} - {% if firmwareless_count %} -

    {% trans "Devices which the system has never upgraded yet" %} ({{ firmwareless_count }})

    -
      - {% for device in firmwareless_devices %} -
    • {{ device.name }}
    • - {% endfor %} -
    +

    + {% blocktrans %} + No devices to upgrade were found. + Hint: Maybe they have been all already updated previously. + {% endblocktrans %} +

    {% endif %} -
    {% csrf_token %} -
    - - - {% if firmwareless_count or related_count %} -
    {% if form.errors %}{{ form.errors }} {% endif %}
    -
    - {{ form }} -
    - - {% if firmwareless_count and related_count %} - - {% endif %} - {% endif %} - {% if related_count or firmwareless_count %} - {% trans "Cancel the upgrade" %} - {% else %} - {% trans "Go back" %} - {% endif %} - -
    + + {% csrf_token %} +
    + + + {% if firmwareless_count or related_count %} + {% if form.errors %} +
    {{ form.errors }}
    + {% endif %} + {{ form.build }} +
    +

    {% trans "Upgrade Filters" %}

    +
    +
    +
    + {{ form.group.errors }} + {{ form.group.label_tag }} + {{ form.group }} +
    +
    + {{ form.group.help_text|safe }} +
    +
    +
    +
    +
    +
    + {{ form.location.errors }} + {{ form.location.label_tag }} + {{ form.location }} +
    +
    + {{ form.location.help_text|safe }} +
    +
    +
    +
    +
    + {{ form.upgrade_options.label_tag }} + {{ form.upgrade_options }} +
    +
    +
    + + {% if firmwareless_count and related_count %} + + {% endif %} + {% endif %} + {% if related_count or firmwareless_count %}{% trans "Cancel the upgrade" %}{% else %}{% trans "Go back" %}{% endif %} +
    {% endblock %} - {% block footer %} -{{ block.super }} - -{{ form.media }} + {{ block.super }} + {% endblock footer %} diff --git a/openwisp_firmware_upgrader/tests/base.py b/openwisp_firmware_upgrader/tests/base.py index e8d59f68c..236658785 100644 --- a/openwisp_firmware_upgrader/tests/base.py +++ b/openwisp_firmware_upgrader/tests/base.py @@ -9,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from openwisp_controller.connection.tests.utils import CreateConnectionsMixin +from openwisp_utils.tests.selenium import SeleniumTestMixin from ..swapper import load_model @@ -16,8 +17,8 @@ Category = load_model("Category") FirmwareImage = load_model("FirmwareImage") DeviceFirmware = load_model("DeviceFirmware") -DeviceFirmware = load_model("DeviceFirmware") Credentials = swapper.load_model("connection", "Credentials") +DeviceGroup = swapper.load_model("config", "DeviceGroup") OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") @@ -153,6 +154,7 @@ def _create_upgrade_env( build=build2, type=self.TPLINK_4300_IL_IMAGE ) data = { + "category": category, "build1": build1, "build2": build2, "d1": d1, @@ -202,6 +204,30 @@ def _create_device_with_connection(self, **kwargs): self._create_device_connection(device=d1) return d1 + def _create_device_group(self, **kwargs): + """Create a device group for testing.""" + opts = dict(name="Test Group") + opts.update(kwargs) + if "organization" not in opts: + opts["organization"] = self._get_org() + group = DeviceGroup(**opts) + group.full_clean() + group.save() + return group + + +class SeleniumTestMixin(SeleniumTestMixin): + def _assert_no_js_errors(self): + browser_logs = [] + for log in self.get_browser_logs(): + # capture SEVERE-level entries from both JS runtime and console API + if log.get("level") == "SEVERE" and log.get("source") in ( + "javascript", + "console-api", + ): + browser_logs.append(log) + self.assertEqual(browser_logs, []) + def spy_mock(method, pre_action): magicmock = mock.MagicMock() diff --git a/openwisp_firmware_upgrader/tests/test_admin.py b/openwisp_firmware_upgrader/tests/test_admin.py index 06e8bd5a7..6e39a487c 100644 --- a/openwisp_firmware_upgrader/tests/test_admin.py +++ b/openwisp_firmware_upgrader/tests/test_admin.py @@ -14,6 +14,7 @@ from openwisp_controller.config.tests.test_admin import TestAdmin as TestConfigAdmin from openwisp_controller.connection import settings as conn_settings from openwisp_firmware_upgrader.admin import ( + BatchUpgradeConfirmationForm, BuildAdmin, DeviceAdmin, DeviceFirmwareForm, @@ -39,6 +40,9 @@ UpgradeOperation = load_model("UpgradeOperation") BatchUpgradeOperation = load_model("BatchUpgradeOperation") Device = swapper.load_model("config", "Device") +Location = swapper.load_model("geo", "Location") +DeviceLocation = swapper.load_model("geo", "DeviceLocation") +DeviceConnection = swapper.load_model("connection", "DeviceConnection") class MockRequest: @@ -151,7 +155,7 @@ def test_upgrade_selected_error(self): def test_upgrade_intermediate_page_related(self): self._login() env = self._create_upgrade_env() - with self.assertNumQueries(13): + with self.assertNumQueries(15): r = self.client.post( self.build_list_url, { @@ -160,14 +164,12 @@ def test_upgrade_intermediate_page_related(self): }, follow=True, ) - self.assertContains(r, "Devices related to build") - self.assertNotContains(r, "has never upgraded yet") self.assertNotContains(r, '{org.name}' + + org1_option = _get_org_option(org1) + org2_option = _get_org_option(org2) + with self.subTest( + "Superuser: Organization filter is visible for shared category" + ): + self._login() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "By organization") + self.assertContains( + response, + org1_option, + html=True, + ) + self.assertContains( + response, + org2_option, + html=True, + ) + + with self.subTest("Org admin: Cannot view shared mass upgrade operation"): + self.client.force_login(org1_admin) + response = self.client.get(url, follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.request["PATH_INFO"], reverse("admin:index")) + self.assertContains( + response, + f'
  • Mass upgrade operation with ID “{batch.pk}”' + " doesn’t exist. Perhaps it was deleted?
  • ", + html=True, + ) + + env["category"].organization = org1 + env["category"].save() + with self.subTest( + "Superuser: Organization filter hidden when category belongs to org" + ): + self._login() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "By organization") + + with self.subTest( + "Org admin: Organization filter hidden when category belongs to org" + ): + self.client.force_login(org1_admin) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "By organization") + + def test_batch_upgrade_operation_filters(self, *args): + """Test that filter UI elements are displayed correctly for organization admin""" + env = self._create_upgrade_env() + org_admin = self._create_administrator(organizations=[env["d1"].organization]) + self.client.force_login(org_admin) + batch = env["build2"].batch_upgrade(firmwareless=True) + url = reverse( + f"admin:{self.app_label}_batchupgradeoperation_change", args=[batch.pk] + ) + + with self.subTest("Test filter UI elements are present"): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + # Check status filter options + self.assertContains(response, "By status") + self.assertContains(response, 'title="in progress"') + self.assertContains(response, 'title="success"') + self.assertContains(response, 'title="failed"') + # Organization filter should not be present because the build's category is not shared + self.assertNotContains(response, "By organization") + + with self.subTest("Test active filter indication"): + # Test with status filter active + response = self.client.get(url + "?status=in-progress") + self.assertEqual(response.status_code, 200) + # Check that the in-progress status is selected + self.assertContains( + response, + ( + '' + "in progress" + ), + html=True, + ) + + with self.subTest("Filter link building preserves other GET params"): + # Apply search and status simultaneously and verify the generated + # "All" choice keeps the search parameter when building its + # query string. The href for the idle option will still contain + # both params, but the important part is the all link. + response = self.client.get(url + "?q=testsearch&status=idle") + self.assertEqual(response.status_code, 200) + content = response.content.decode() + self.assertIn('href="?q=testsearch"', content) + self.assertContains( + response, + 'All', + html=True, + ) + _mock_upgrade = "openwisp_firmware_upgrader.upgraders.openwrt.OpenWrt.upgrade" _mock_connect = "openwisp_controller.connection.models.DeviceConnection.connect" @@ -559,8 +676,7 @@ def test_upgrade_selected_action_perms(self, *args): obj=env["build1"], message=( "You can track the progress of this mass upgrade operation " - "in this page. Refresh the page from time to time to check " - "its progress." + "in this page." ), required_perms=["change"], extra_payload={ @@ -887,7 +1003,7 @@ def test_using_upgrade_options_with_unsupported_upgrader(self, *args): reverse("admin:config_device_change", args=[device.id]) ) self.assertContains( - response, "" + response, "