Просмотр исходного кода

Merge branch 'CURA-7290_manual_account_sync' of github.com:Ultimaker/Cura

Jaime van Kessel 4 лет назад
Родитель
Сommit
36cdf2edbe

+ 94 - 2
cura/API/Account.py

@@ -1,9 +1,11 @@
 # Copyright (c) 2018 Ultimaker B.V.
 # Cura is released under the terms of the LGPLv3 or higher.
-from typing import Optional, Dict, TYPE_CHECKING
+from datetime import datetime
+from typing import Optional, Dict, TYPE_CHECKING, Union
 
-from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
+from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
 
+from UM.Logger import Logger
 from UM.Message import Message
 from UM.i18n import i18nCatalog
 from cura.OAuth2.AuthorizationService import AuthorizationService
@@ -16,6 +18,13 @@ if TYPE_CHECKING:
 i18n_catalog = i18nCatalog("cura")
 
 
+class SyncState:
+    """QML: Cura.AccountSyncState"""
+    SYNCING = 0
+    SUCCESS = 1
+    ERROR = 2
+
+
 ##  The account API provides a version-proof bridge to use Ultimaker Accounts
 #
 #   Usage:
@@ -26,9 +35,21 @@ i18n_catalog = i18nCatalog("cura")
 #       api.account.userProfile # Who is logged in``
 #
 class Account(QObject):
+    # The interval in which sync services are automatically triggered
+    SYNC_INTERVAL = 30.0  # seconds
+    Q_ENUMS(SyncState)
+
     # Signal emitted when user logged in or out.
     loginStateChanged = pyqtSignal(bool)
     accessTokenChanged = pyqtSignal()
+    syncRequested = pyqtSignal()
+    """Sync services may connect to this signal to receive sync triggers.
+    Services should be resilient to receiving a signal while they are still syncing,
+    either by ignoring subsequent signals or restarting a sync.
+    See setSyncState() for providing user feedback on the state of your service. 
+    """
+    lastSyncDateTimeChanged = pyqtSignal()
+    syncStateChanged = pyqtSignal(int)  # because SyncState is an int Enum
 
     def __init__(self, application: "CuraApplication", parent = None) -> None:
         super().__init__(parent)
@@ -37,6 +58,8 @@ class Account(QObject):
 
         self._error_message = None  # type: Optional[Message]
         self._logged_in = False
+        self._sync_state = SyncState.SUCCESS
+        self._last_sync_str = "-"
 
         self._callback_port = 32118
         self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot
@@ -56,6 +79,16 @@ class Account(QObject):
 
         self._authorization_service = AuthorizationService(self._oauth_settings)
 
+        # Create a timer for automatic account sync
+        self._update_timer = QTimer()
+        self._update_timer.setInterval(int(self.SYNC_INTERVAL * 1000))
+        # The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates
+        self._update_timer.setSingleShot(True)
+        self._update_timer.timeout.connect(self.syncRequested)
+
+        self._sync_services = {}  # type: Dict[str, int]
+        """contains entries "service_name" : SyncState"""
+
     def initialize(self) -> None:
         self._authorization_service.initialize(self._application.getPreferences())
         self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged)
@@ -63,6 +96,39 @@ class Account(QObject):
         self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
         self._authorization_service.loadAuthDataFromPreferences()
 
+    def setSyncState(self, service_name: str, state: int) -> None:
+        """ Can be used to register sync services and update account sync states
+
+        Contract: A sync service is expected exit syncing state in all cases, within reasonable time
+
+        Example: `setSyncState("PluginSyncService", SyncState.SYNCING)`
+        :param service_name: A unique name for your service, such as `plugins` or `backups`
+        :param state: One of SyncState
+        """
+
+        prev_state = self._sync_state
+
+        self._sync_services[service_name] = state
+
+        if any(val == SyncState.SYNCING for val in self._sync_services.values()):
+            self._sync_state = SyncState.SYNCING
+        elif any(val == SyncState.ERROR for val in self._sync_services.values()):
+            self._sync_state = SyncState.ERROR
+        else:
+            self._sync_state = SyncState.SUCCESS
+
+        if self._sync_state != prev_state:
+            self.syncStateChanged.emit(self._sync_state)
+
+            if self._sync_state == SyncState.SUCCESS:
+                self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M")
+                self.lastSyncDateTimeChanged.emit()
+
+            if self._sync_state != SyncState.SYNCING:
+                # schedule new auto update after syncing completed (for whatever reason)
+                if not self._update_timer.isActive():
+                    self._update_timer.start()
+
     def _onAccessTokenChanged(self):
         self.accessTokenChanged.emit()
 
@@ -83,11 +149,18 @@ class Account(QObject):
             self._error_message.show()
             self._logged_in = False
             self.loginStateChanged.emit(False)
+            if self._update_timer.isActive():
+                self._update_timer.stop()
             return
 
         if self._logged_in != logged_in:
             self._logged_in = logged_in
             self.loginStateChanged.emit(logged_in)
+            if logged_in:
+                self.sync()
+            else:
+                if self._update_timer.isActive():
+                    self._update_timer.stop()
 
     @pyqtSlot()
     def login(self) -> None:
@@ -123,6 +196,25 @@ class Account(QObject):
             return None
         return user_profile.__dict__
 
+    @pyqtProperty(str, notify=lastSyncDateTimeChanged)
+    def lastSyncDateTime(self) -> str:
+        return self._last_sync_str
+
+    @pyqtSlot()
+    def sync(self) -> None:
+        """Signals all sync services to start syncing
+
+        This can be considered a forced sync: even when a
+        sync is currently running, a sync will be requested.
+        """
+
+        if self._update_timer.isActive():
+            self._update_timer.stop()
+        elif self._sync_state == SyncState.SYNCING:
+            Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
+
+        self.syncRequested.emit()
+
     @pyqtSlot()
     def logout(self) -> None:
         if not self._logged_in:

+ 2 - 0
cura/CuraApplication.py

@@ -48,6 +48,7 @@ from UM.Workspace.WorkspaceReader import WorkspaceReader
 from UM.i18n import i18nCatalog
 from cura import ApplicationMetadata
 from cura.API import CuraAPI
+from cura.API.Account import Account
 from cura.Arranging.Arrange import Arrange
 from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob
 from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
@@ -1113,6 +1114,7 @@ class CuraApplication(QtApplication):
 
         from cura.API import CuraAPI
         qmlRegisterSingletonType(CuraAPI, "Cura", 1, 1, "API", self.getCuraAPI)
+        qmlRegisterUncreatableType(Account, "Cura", 1, 0, "AccountSyncState", "Could not create AccountSyncState")
 
         # As of Qt5.7, it is necessary to get rid of any ".." in the path for the singleton to work.
         actions_url = QUrl.fromLocalFile(os.path.abspath(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml")))

+ 26 - 4
plugins/Toolbox/src/CloudSync/CloudPackageChecker.py

@@ -2,7 +2,7 @@
 # Cura is released under the terms of the LGPLv3 or higher.
 
 import json
-from typing import List, Dict, Any
+from typing import List, Dict, Any, Set
 from typing import Optional
 
 from PyQt5.QtCore import QObject
@@ -13,6 +13,7 @@ from UM.Logger import Logger
 from UM.Message import Message
 from UM.Signal import Signal
 from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
+from cura.API.Account import SyncState
 from cura.CuraApplication import CuraApplication, ApplicationMetadata
 from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
 from .SubscribedPackagesModel import SubscribedPackagesModel
@@ -20,6 +21,9 @@ from ..CloudApiModel import CloudApiModel
 
 
 class CloudPackageChecker(QObject):
+
+    SYNC_SERVICE_NAME = "CloudPackageChecker"
+
     def __init__(self, application: CuraApplication) -> None:
         super().__init__()
 
@@ -32,23 +36,32 @@ class CloudPackageChecker(QObject):
         self._application.initializationFinished.connect(self._onAppInitialized)
         self._i18n_catalog = i18nCatalog("cura")
         self._sdk_version = ApplicationMetadata.CuraSDKVersion
+        self._last_notified_packages = set()  # type: Set[str]
+        """Packages for which a notification has been shown. No need to bother the user twice fo equal content"""
 
     # This is a plugin, so most of the components required are not ready when
     # this is initialized. Therefore, we wait until the application is ready.
     def _onAppInitialized(self) -> None:
         self._package_manager = self._application.getPackageManager()
         # initial check
-        self._onLoginStateChanged()
-        # check again whenever the login state changes
+        self._getPackagesIfLoggedIn()
+
         self._application.getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged)
+        self._application.getCuraAPI().account.syncRequested.connect(self._getPackagesIfLoggedIn)
 
     def _onLoginStateChanged(self) -> None:
+        # reset session
+        self._last_notified_packages = set()
+        self._getPackagesIfLoggedIn()
+
+    def _getPackagesIfLoggedIn(self) -> None:
         if self._application.getCuraAPI().account.isLoggedIn:
             self._getUserSubscribedPackages()
         else:
             self._hideSyncMessage()
 
     def _getUserSubscribedPackages(self) -> None:
+        self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING)
         Logger.debug("Requesting subscribed packages metadata from server.")
         url = CloudApiModel.api_url_user_packages
         self._application.getHttpRequestManager().get(url,
@@ -61,6 +74,7 @@ class CloudPackageChecker(QObject):
             Logger.log("w",
                        "Requesting user packages failed, response code %s while trying to connect to %s",
                        reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url())
+            self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)
             return
 
         try:
@@ -69,15 +83,22 @@ class CloudPackageChecker(QObject):
             if "errors" in json_data:
                 for error in json_data["errors"]:
                     Logger.log("e", "%s", error["title"])
+                    self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)
                 return
             self._handleCompatibilityData(json_data["data"])
         except json.decoder.JSONDecodeError:
             Logger.log("w", "Received invalid JSON for user subscribed packages from the Web Marketplace")
 
+        self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS)
+
     def _handleCompatibilityData(self, subscribed_packages_payload: List[Dict[str, Any]]) -> None:
         user_subscribed_packages = {plugin["package_id"] for plugin in subscribed_packages_payload}
         user_installed_packages = self._package_manager.getAllInstalledPackageIDs()
 
+        if user_subscribed_packages == self._last_notified_packages:
+            # already notified user about these
+            return
+
         # We need to re-evaluate the dismissed packages
         # (i.e. some package might got updated to the correct SDK version in the meantime,
         # hence remove them from the Dismissed Incompatible list)
@@ -87,12 +108,13 @@ class CloudPackageChecker(QObject):
             user_installed_packages += user_dismissed_packages
 
         # We check if there are packages installed in Web Marketplace but not in Cura marketplace
-        package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages))
+        package_discrepancy = list(user_subscribed_packages.difference(user_installed_packages))
         if package_discrepancy:
             Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages")
             self._model.addDiscrepancies(package_discrepancy)
             self._model.initialize(self._package_manager, subscribed_packages_payload)
             self._showSyncMessage()
+            self._last_notified_packages = user_subscribed_packages
 
     def _showSyncMessage(self) -> None:
         """Show the message if it is not already shown"""

+ 12 - 4
plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py

@@ -53,10 +53,10 @@ class CloudApiClient:
 
     ## Retrieves all the clusters for the user that is currently logged in.
     #  \param on_finished: The function to be called after the result is parsed.
-    def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any]) -> None:
+    def getClusters(self, on_finished: Callable[[List[CloudClusterResponse]], Any], failed: Callable) -> None:
         url = "{}/clusters?status=active".format(self.CLUSTER_API_ROOT)
         reply = self._manager.get(self._createEmptyRequest(url))
-        self._addCallback(reply, on_finished, CloudClusterResponse)
+        self._addCallback(reply, on_finished, CloudClusterResponse, failed)
 
     ## Retrieves the status of the given cluster.
     #  \param cluster_id: The ID of the cluster.
@@ -166,16 +166,24 @@ class CloudApiClient:
                      reply: QNetworkReply,
                      on_finished: Union[Callable[[CloudApiClientModel], Any],
                                         Callable[[List[CloudApiClientModel]], Any]],
-                     model: Type[CloudApiClientModel]) -> None:
+                     model: Type[CloudApiClientModel],
+                     on_error: Optional[Callable] = None) -> None:
         def parse() -> None:
             self._anti_gc_callbacks.remove(parse)
 
             # Don't try to parse the reply if we didn't get one
             if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
+                if on_error is not None:
+                    on_error()
                 return
 
             status_code, response = self._parseReply(reply)
-            self._parseModels(response, on_finished, model)
+            if status_code >= 300 and on_error is not None:
+                on_error()
+            else:
+                self._parseModels(response, on_finished, model)
 
         self._anti_gc_callbacks.append(parse)
         reply.finished.connect(parse)
+        if on_error is not None:
+            reply.error.connect(on_error)

+ 21 - 17
plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py

@@ -10,6 +10,7 @@ from UM.Logger import Logger  # To log errors talking to the API.
 from UM.Message import Message
 from UM.Signal import Signal
 from cura.API import Account
+from cura.API.Account import SyncState
 from cura.CuraApplication import CuraApplication
 from cura.Settings.CuraStackBuilder import CuraStackBuilder
 from cura.Settings.GlobalStack import GlobalStack
@@ -27,9 +28,7 @@ class CloudOutputDeviceManager:
 
     META_CLUSTER_ID = "um_cloud_cluster_id"
     META_NETWORK_KEY = "um_network_key"
-
-    # The interval with which the remote clusters are checked
-    CHECK_CLUSTER_INTERVAL = 30.0  # seconds
+    SYNC_SERVICE_NAME = "CloudOutputDeviceManager"
 
     # The translation catalog for this device.
     I18N_CATALOG = i18nCatalog("cura")
@@ -44,16 +43,11 @@ class CloudOutputDeviceManager:
         self._api = CloudApiClient(self._account, on_error = lambda error: Logger.log("e", str(error)))
         self._account.loginStateChanged.connect(self._onLoginStateChanged)
 
-        # Create a timer to update the remote cluster list
-        self._update_timer = QTimer()
-        self._update_timer.setInterval(int(self.CHECK_CLUSTER_INTERVAL * 1000))
-        # The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates
-        self._update_timer.setSingleShot(True)
-        self._update_timer.timeout.connect(self._getRemoteClusters)
-
         # Ensure we don't start twice.
         self._running = False
 
+        self._syncing = False
+
     def start(self):
         """Starts running the cloud output device manager, thus periodically requesting cloud data."""
 
@@ -62,18 +56,16 @@ class CloudOutputDeviceManager:
         if not self._account.isLoggedIn:
             return
         self._running = True
-        if not self._update_timer.isActive():
-            self._update_timer.start()
         self._getRemoteClusters()
 
+        self._account.syncRequested.connect(self._getRemoteClusters)
+
     def stop(self):
         """Stops running the cloud output device manager."""
 
         if not self._running:
             return
         self._running = False
-        if self._update_timer.isActive():
-            self._update_timer.stop()
         self._onGetRemoteClustersFinished([])  # Make sure we remove all cloud output devices.
 
     def refreshConnections(self) -> None:
@@ -92,7 +84,14 @@ class CloudOutputDeviceManager:
     def _getRemoteClusters(self) -> None:
         """Gets all remote clusters from the API."""
 
-        self._api.getClusters(self._onGetRemoteClustersFinished)
+        if self._syncing:
+            return
+
+        Logger.info("Syncing cloud printer clusters")
+
+        self._syncing = True
+        self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING)
+        self._api.getClusters(self._onGetRemoteClustersFinished, self._onGetRemoteClusterFailed)
 
     def _onGetRemoteClustersFinished(self, clusters: List[CloudClusterResponse]) -> None:
         """Callback for when the request for getting the clusters is finished."""
@@ -115,8 +114,13 @@ class CloudOutputDeviceManager:
         if removed_device_keys:
             # If the removed device was active we should connect to the new active device
             self._connectToActiveMachine()
-        # Schedule a new update
-        self._update_timer.start()
+
+        self._syncing = False
+        self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS)
+
+    def _onGetRemoteClusterFailed(self):
+        self._syncing = False
+        self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR)
 
     def _onDevicesDiscovered(self, clusters: List[CloudClusterResponse]) -> None:
         """**Synchronously** create machines for discovered devices

+ 110 - 0
resources/qml/Account/SyncState.qml

@@ -0,0 +1,110 @@
+import QtQuick 2.10
+import QtQuick.Controls 2.3
+
+import UM 1.4 as UM
+import Cura 1.1 as Cura
+
+Row // sync state icon + message
+{
+
+    property alias iconSource: icon.source
+    property alias labelText: stateLabel.text
+    property alias syncButtonVisible: accountSyncButton.visible
+    property alias animateIconRotation: updateAnimator.running
+
+    width: childrenRect.width
+    height: childrenRect.height
+    anchors.horizontalCenter: parent.horizontalCenter
+    spacing: UM.Theme.getSize("narrow_margin").height
+
+    UM.RecolorImage
+    {
+        id: icon
+        width: 20 * screenScaleFactor
+        height: width
+
+        source: UM.Theme.getIcon("update")
+        color: palette.text
+
+        RotationAnimator
+        {
+            id: updateAnimator
+            target: icon
+            from: 0
+            to: 360
+            duration: 1000
+            loops: Animation.Infinite
+            running: true
+
+            // reset rotation when stopped
+            onRunningChanged: {
+                if(!running)
+                {
+                    icon.rotation = 0
+                }
+            }
+        }
+    }
+
+    Column
+    {
+        width: childrenRect.width
+        height: childrenRect.height
+
+        Label
+        {
+            id: stateLabel
+            text: catalog.i18nc("@state", "Checking...")
+            color: UM.Theme.getColor("text")
+            font: UM.Theme.getFont("medium")
+            renderType: Text.NativeRendering
+        }
+
+        Label
+        {
+            id: accountSyncButton
+            text: catalog.i18nc("@button", "Check for account updates")
+            color: UM.Theme.getColor("secondary_button_text")
+            font: UM.Theme.getFont("medium")
+            renderType: Text.NativeRendering
+
+            MouseArea
+            {
+                anchors.fill: parent
+                onClicked: Cura.API.account.sync()
+                hoverEnabled: true
+                onEntered: accountSyncButton.font.underline = true
+                onExited: accountSyncButton.font.underline = false
+            }
+        }
+    }
+
+    signal syncStateChanged(string newState)
+
+    onSyncStateChanged: {
+        if(newState == Cura.AccountSyncState.SYNCING){
+            syncRow.iconSource = UM.Theme.getIcon("update")
+            syncRow.labelText = catalog.i18nc("@label", "Checking...")
+        } else if (newState == Cura.AccountSyncState.SUCCESS) {
+            syncRow.iconSource = UM.Theme.getIcon("checked")
+            syncRow.labelText = catalog.i18nc("@label", "You are up to date")
+        } else if (newState == Cura.AccountSyncState.ERROR) {
+            syncRow.iconSource = UM.Theme.getIcon("warning_light")
+            syncRow.labelText = catalog.i18nc("@label", "Something went wrong...")
+        } else {
+            print("Error: unexpected sync state: " + newState)
+        }
+
+        if(newState == Cura.AccountSyncState.SYNCING){
+            syncRow.animateIconRotation = true
+            syncRow.syncButtonVisible = false
+        } else {
+            syncRow.animateIconRotation = false
+            syncRow.syncButtonVisible = true
+        }
+    }
+
+    Component.onCompleted: Cura.API.account.syncStateChanged.connect(syncStateChanged)
+
+
+}

+ 24 - 0
resources/qml/Account/UserOperations.qml

@@ -13,6 +13,11 @@ Column
 
     spacing: UM.Theme.getSize("default_margin").height
 
+    SystemPalette
+    {
+        id: palette
+    }
+
     Label
     {
         id: title
@@ -24,6 +29,24 @@ Column
         color: UM.Theme.getColor("text")
     }
 
+    SyncState
+    {
+        id: syncRow
+    }
+
+
+
+    Label
+    {
+        id: lastSyncLabel
+        anchors.horizontalCenter: parent.horizontalCenter
+        horizontalAlignment: Text.AlignHCenter
+        renderType: Text.NativeRendering
+        text: catalog.i18nc("@label The argument is a timestamp", "Last update: %1").arg(Cura.API.account.lastSyncDateTime)
+        font: UM.Theme.getFont("default")
+        color: UM.Theme.getColor("text_medium")
+    }
+
     Cura.SecondaryButton
     {
         id: accountButton
@@ -53,4 +76,5 @@ Column
             onExited: signOutButton.font.underline = false
         }
     }
+
 }

+ 12 - 0
resources/themes/cura-light/icons/checked.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
+    <title>checked</title>
+    <desc>Created with Sketch.</desc>
+    <g id="checked" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Check-circle" fill="#000000" fill-rule="nonzero">
+            <path d="M8,0 C3.581722,0 1.42108547e-15,3.581722 1.42108547e-15,8 C1.42108547e-15,12.418278 3.581722,16 8,16 C12.418278,16 16,12.418278 16,8 C16,5.87826808 15.1571453,3.84343678 13.6568542,2.34314575 C12.1565632,0.842854723 10.1217319,0 8,0 Z M8,14.4 C4.4653776,14.4 1.6,11.5346224 1.6,8 C1.6,4.4653776 4.4653776,1.6 8,1.6 C11.5346224,1.6 14.4,4.4653776 14.4,8 C14.4,9.69738553 13.7257162,11.3252506 12.5254834,12.5254834 C11.3252506,13.7257162 9.69738553,14.4 8,14.4 Z" id="Shape"></path>
+            <polygon id="Path" points="11.44 5.04 7.2 9.28 4.56 6.64 3.44 7.76 7.2 11.52 12.56 6.16"></polygon>
+        </g>
+    </g>
+</svg>

+ 12 - 0
resources/themes/cura-light/icons/update.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
+    <title>update</title>
+    <desc>Created with Sketch.</desc>
+    <g id="update" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Update" transform="translate(1.000000, 0.000000)" fill="#000000" fill-rule="nonzero">
+            <path d="M12.8,8 C12.8,11.0927946 10.2927946,13.6 7.2,13.6 C5.73088329,13.6179028 4.31699148,13.0408041 3.28,12 L6.4,12 L6.4,10.4 L0.8,10.4 L0.8,16 L2.4,16 L2.4,13.36 C3.71086874,14.5564475 5.42526687,15.2136335 7.2,15.2 C11.1764502,15.2 14.4,11.9764502 14.4,8 L12.8,8 Z" id="Path"></path>
+            <path d="M7.2,2.4 C8.66911671,2.38209724 10.0830085,2.95919594 11.12,4 L8,4 L8,5.6 L13.6,5.6 L13.6,0 L12,0 L12,2.64 C10.6891313,1.44355247 8.97473313,0.786366515 7.2,0.8 C3.2235498,0.8 0,4.0235498 0,8 L1.6,8 C1.6,4.9072054 4.1072054,2.4 7.2,2.4 Z" id="Path"></path>
+        </g>
+    </g>
+</svg>

+ 13 - 0
resources/themes/cura-light/icons/warning_light.svg

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
+    <title>warning</title>
+    <desc>Created with Sketch.</desc>
+    <g id="warning" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Warning" transform="translate(0.000000, 1.000000)" fill="#000000" fill-rule="nonzero">
+            <path d="M15.2727273,11.6363636 L9.23636364,0.8 C9.00246505,0.33139139 8.52373903,0.0352931909 8,0.0352931909 C7.47626097,0.0352931909 6.99753495,0.33139139 6.76363636,0.8 L0.727272727,11.6363636 C0.433127714,12.0765996 0.433127714,12.6506731 0.727272727,13.0909091 C0.97144313,13.5450025 1.44810945,13.8253945 1.96363636,13.8183177 L14.0363636,13.8183177 C14.5518905,13.8253945 15.0285569,13.5450025 15.2727273,13.0909091 C15.5668723,12.6506731 15.5668723,12.0765996 15.2727273,11.6363636 L15.2727273,11.6363636 Z M1.96363636,12.3636364 L8,1.52727273 L14.0363636,12.3636364 L1.96363636,12.3636364 Z" id="Shape"></path>
+            <rect id="Rectangle" x="7.27272727" y="4.36363636" width="1.45454545" height="4.36363636"></rect>
+            <rect id="Rectangle" x="7.27272727" y="10.1818182" width="1.45454545" height="1.45454545"></rect>
+        </g>
+    </g>
+</svg>