diff --git a/pkg/hypervisor/hypervisor.go b/pkg/hypervisor/hypervisor.go index 84158679dd..df96f6f0b0 100644 --- a/pkg/hypervisor/hypervisor.go +++ b/pkg/hypervisor/hypervisor.go @@ -68,7 +68,7 @@ type Hypervisor struct { updater *updater.Updater mu *sync.RWMutex visorMu sync.Mutex - visorChanMux *chanMux + visorChanMux map[cipher.PubKey]*chanMux hypervisorMu sync.Mutex hypervisorChanMux *chanMux } @@ -87,15 +87,16 @@ func New(config Config, assets http.FileSystem, restartCtx *restart.Context, dms u := updater.New(log, restartCtx, "") hv := &Hypervisor{ - c: config, - dmsgC: dmsgC, - assets: assets, - visors: make(map[cipher.PubKey]VisorConn), - trackers: NewDmsgTrackerManager(nil, dmsgC, 0, 0), - users: NewUserManager(singleUserDB, config.Cookies), - restartCtx: restartCtx, - updater: u, - mu: new(sync.RWMutex), + c: config, + dmsgC: dmsgC, + assets: assets, + visors: make(map[cipher.PubKey]VisorConn), + trackers: NewDmsgTrackerManager(nil, dmsgC, 0, 0), + users: NewUserManager(singleUserDB, config.Cookies), + restartCtx: restartCtx, + updater: u, + mu: new(sync.RWMutex), + visorChanMux: make(map[cipher.PubKey]*chanMux), } return hv, nil @@ -351,17 +352,17 @@ func (hv *Hypervisor) updateHypervisorWS() http.HandlerFunc { consumer := make(chan visor.StatusMessage, 512) hv.hypervisorMu.Lock() - if hv.visorChanMux == nil { + if hv.hypervisorChanMux == nil { ch := hv.updateHVWithStatus(updateConfig) - hv.visorChanMux = newChanMux(ch, []chan<- visor.StatusMessage{consumer}) + hv.hypervisorChanMux = newChanMux(ch, []chan<- visor.StatusMessage{consumer}) } else { - hv.visorChanMux.addConsumer(consumer) + hv.hypervisorChanMux.addConsumer(consumer) } hv.hypervisorMu.Unlock() defer func() { hv.hypervisorMu.Lock() - hv.visorChanMux = nil + hv.hypervisorChanMux = nil hv.hypervisorMu.Unlock() }() @@ -1105,17 +1106,17 @@ func (hv *Hypervisor) updateVisorWS() http.HandlerFunc { consumer := make(chan visor.StatusMessage, 512) hv.visorMu.Lock() - if hv.visorChanMux == nil { + if mux := hv.visorChanMux[ctx.Addr.PK]; mux == nil { ch := ctx.RPC.UpdateWithStatus(updateConfig) - hv.visorChanMux = newChanMux(ch, []chan<- visor.StatusMessage{consumer}) + hv.visorChanMux[ctx.Addr.PK] = newChanMux(ch, []chan<- visor.StatusMessage{consumer}) } else { - hv.visorChanMux.addConsumer(consumer) + hv.visorChanMux[ctx.Addr.PK].addConsumer(consumer) } hv.visorMu.Unlock() defer func() { hv.visorMu.Lock() - hv.visorChanMux = nil + delete(hv.visorChanMux, ctx.Addr.PK) hv.visorMu.Unlock() }() @@ -1152,7 +1153,7 @@ func (hv *Hypervisor) isVisorWSUpdateRunning() http.HandlerFunc { return hv.withCtx(hv.visorCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { running := false hv.visorMu.Lock() - running = hv.visorChanMux != nil + running = hv.visorChanMux != nil && hv.visorChanMux[ctx.Addr.PK] != nil hv.visorMu.Unlock() resp := struct { diff --git a/static/skywire-manager-src/proxy.config.json b/static/skywire-manager-src/proxy.config.json index 4d59f228d0..c9e4fde46c 100644 --- a/static/skywire-manager-src/proxy.config.json +++ b/static/skywire-manager-src/proxy.config.json @@ -11,5 +11,23 @@ "pathRewrite": { "^/http-api" : "/api" } + }, + "/wss-api": { + "target": "wss://127.0.0.1:8000", + "secure": false, + "ws": true, + "headers": {"host":"127.0.0.1:8000", "origin":"wss://127.0.0.1:8000", "referer":"wss://127.0.0.1:8000"}, + "pathRewrite": { + "^/wss-api" : "/api" + } + }, + "/ws-api": { + "target": "ws://127.0.0.1:8000", + "secure": false, + "ws": true, + "headers": {"host":"127.0.0.1:8000", "origin":"ws://127.0.0.1:8000", "referer":"ws://127.0.0.1:8000"}, + "pathRewrite": { + "^/ws-api" : "/api" + } } } diff --git a/static/skywire-manager-src/src/app/app.module.ts b/static/skywire-manager-src/src/app/app.module.ts index e4b37aaa86..66ecaca12f 100644 --- a/static/skywire-manager-src/src/app/app.module.ts +++ b/static/skywire-manager-src/src/app/app.module.ts @@ -77,6 +77,7 @@ import { FiltersSelectionComponent } from './components/layout/filters-selection import { LabeledElementTextComponent } from './components/layout/labeled-element-text/labeled-element-text.component'; import { AllLabelsComponent } from './components/pages/settings/all-labels/all-labels.component'; import { LabelListComponent } from './components/pages/settings/all-labels/label-list/label-list.component'; +import { UpdateComponent } from './components/layout/update/update.component'; const globalRippleConfig: RippleGlobalOptions = { disabled: true, @@ -136,6 +137,7 @@ const globalRippleConfig: RippleGlobalOptions = { LabeledElementTextComponent, AllLabelsComponent, LabelListComponent, + UpdateComponent, ], imports: [ BrowserModule, diff --git a/static/skywire-manager-src/src/app/components/layout/update/update.component.html b/static/skywire-manager-src/src/app/components/layout/update/update.component.html new file mode 100644 index 0000000000..b28dc38f01 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/layout/update/update.component.html @@ -0,0 +1,119 @@ + + +
+ + + {{ 'update.processing' | translate }} + + + {{ errorText | translate }} + + + {{ (data.length === 1 ? 'update.no-update' : 'update.no-updates') | translate }} + +
+ + +
+ - {{ currentNodeVersion ? currentNodeVersion : ('common.unknown' | translate) }} +
+ + + +
+ {{ 'update.already-updating' | translate }} +
+
+
+ - {{ nodesToUpdate[index].label }} +
+
+
+ + +
+ {{ updateAvailableText | translate:{number: nodesForUpdatesFound} }} +
+
+
+ - {{ 'update.version-change' | translate:update }} +
+
+
+ {{ 'update.update-instructions' | translate }} +
+
+ + + + +
+ {{ 'update.updating' | translate }} +
+
+ + + +
+ - {{ node.label }} + : {{ node.updateProgressInfo.rawMsg }} +  ({{ 'update.finished' | translate }}) +
+ +
+ +
+ + {{ node.label }} +
+ + + + +
+ {{ 'update.downloaded-file-name-prefix' | translate }} {{ node.updateProgressInfo.fileName }} + ({{ node.updateProgressInfo.progress }}%) +
+ {{ 'update.speed-prefix' | translate }} {{ node.updateProgressInfo.speed }} +
+ {{ 'update.time-downloading-prefix' | translate }} {{ node.updateProgressInfo.elapsedTime }} + / + {{ 'update.time-left-prefix' | translate }} {{ node.updateProgressInfo.remainingTime }} + +
+ {{ 'update.finished' | translate }} +
+
+
+ +
+ - {{ node.label }}: {{ node.updateProgressInfo.errorMsg }} +
+
+
+
+
+ + +
+ + {{ cancelButtonText | translate }} + + + {{ confirmButtonText | translate }} + +
+
diff --git a/static/skywire-manager-src/src/app/components/layout/update/update.component.scss b/static/skywire-manager-src/src/app/components/layout/update/update.component.scss new file mode 100644 index 0000000000..5ed87fa701 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/layout/update/update.component.scss @@ -0,0 +1,56 @@ +@import 'variables'; + +.text-container { + word-break: break-word; +} + +.list-container { + font-size: 14px; + margin: 10px; + color: $blue-medium; + word-break: break-word; + + .details { + color: $light-gray; + } +} + +.buttons { + margin-top: 15px; + text-align: right; + + app-button { + margin-left: 5px; + } +} + +.progress-container { + margin: 10px 0; + + .name { + font-size: $font-size-mini; + color: $blue-medium; + } + + ::ng-deep { + .mat-progress-bar-fill::after { + background-color: $blue-medium !important; + } + } + + .details { + font-size: $font-size-mini; + text-align: right; + color: $light-gray; + } +} + +.closed-indication { + color: $yellow; +} + +.loading-indicator { + display: inline-block; + position: relative; + top: 2px; +} diff --git a/static/skywire-manager-src/src/app/components/layout/update/update.component.ts b/static/skywire-manager-src/src/app/components/layout/update/update.component.ts new file mode 100644 index 0000000000..7bca396495 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/layout/update/update.component.ts @@ -0,0 +1,460 @@ +import { Component, Inject, OnDestroy, AfterViewInit, ChangeDetectorRef } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialog, MatDialogConfig } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; +import { Subscription, forkJoin, interval } from 'rxjs'; + +import { AppConfig } from 'src/app/app.config'; +import { NodeService } from 'src/app/services/node.service'; +import { StorageService } from 'src/app/services/storage.service'; +import { OperationError } from 'src/app/utils/operation-error'; +import { processServiceError } from 'src/app/utils/errors'; + +/** + * States of the modal window. + */ +enum UpdatingStates { + /** + * Looking for updates. + */ + InitialProcessing = 'InitialProcessing', + /** + * If no update was found. + */ + NoUpdatesFound = 'NoUpdatesFound', + /** + * Showing the list of updates found and asking for confirmation before installing them. + */ + Asking = 'Asking', + /** + * Installing the updates. + */ + Updating = 'Updating', + /** + * Showing an error msg. Operation cancelled. + */ + Error = 'Error', +} + +/** + * Data about a node to update. + */ +export interface NodeData { + key: string; + label: string; +} + +/** + * Extended data about a node to update, for internal use. + */ +interface NodeToUpdate extends NodeData { + /** + * If there is an update for the node or it was detected as being updated, so the update + * function must be called for it. + */ + update: boolean; + /** + * Info about the current state of the update procedure. + */ + updateProgressInfo: UpdateProgressInfo; +} + +/** + * Info about the current state of the update procedure of a node. + */ +export class UpdateProgressInfo { + /** + * Error found while updating. If it has a valid value, the whole procedure must be + * considered as failed. + */ + errorMsg = ''; + /** + * Raw progress text obtained from the backend. + */ + rawMsg = ''; + /** + * If it was posible to parse the raw progress text obtained from the backend and + * populate the rest of the vars. + */ + dataParsed = false; + /** + * Name of the file currently being downloaded (only is dataParsed === true). + */ + fileName = ''; + /** + * Progress downloading the file, in percentage (only is dataParsed === true). + */ + progress = 100; + /** + * Current download speed (only is dataParsed === true). + */ + speed = ''; + /** + * Time since starting to download the file (only is dataParsed === true). + */ + elapsedTime = ''; + /** + * Expected time for finishing to download the file (only is dataParsed === true). + */ + remainingTime = ''; + /** + * If true, the connection with the backend for getting progress updates has been clo0sed. + */ + closed = false; +} + +/** + * Data about an update found. + */ +interface UpdateVersion { + currentVersion: string; + newVersion: string; +} + +/** + * Modal window used for updating a list of nodes. + */ +@Component({ + selector: 'app-update', + templateUrl: './update.component.html', + styleUrls: ['./update.component.scss'], +}) +export class UpdateComponent implements AfterViewInit, OnDestroy { + // Current state of the window. + state = UpdatingStates.InitialProcessing; + + // Text to show in the cancel button. + cancelButtonText = 'common.cancel'; + // Text to show in the confirm button. + confirmButtonText: string; + // Error msg to show if the current state is UpdatingStates.Error. + errorText: string; + // If it was requested to update only one node and no updates were found, this var contains + // the current version of the node. + currentNodeVersion: string; + + // List with the names of all updates found for the nodes, without repeated values. + updatesFound: UpdateVersion[]; + // List with all the nodes that should be updated. It includes all requested nodes, so it + // may include nodes without updates available and nodes which are already being updated. + nodesToUpdate: NodeToUpdate[]; + // List with the indexes, inside nodesToUpdate, of all nodes which were detected as already + // being updated. + indexesAlreadyBeingUpdated: number[] = []; + // How many nodes inside nodesToUpdate have updates available and are not currently + // being updated. + nodesForUpdatesFound: number; + + updatingStates = UpdatingStates; + + private subscription: Subscription; + private progressSubscriptions: Subscription[]; + private uiUpdateSubscription: Subscription; + + /** + * Opens the modal window. Please use this function instead of opening the window "by hand". + */ + /** + * Opens the modal window. Please use this function instead of opening the window "by hand". + * @param nodes Nodes to update. + */ + public static openDialog(dialog: MatDialog, nodes: NodeData[]): MatDialogRef { + const config = new MatDialogConfig(); + config.data = nodes; + config.autoFocus = false; + config.width = AppConfig.smallModalWidth; + + return dialog.open(UpdateComponent, config); + } + + constructor( + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: NodeData[], + private nodeService: NodeService, + private storageService: StorageService, + private translateService: TranslateService, + private changeDetectorRef: ChangeDetectorRef, + ) { } + + ngAfterViewInit() { + this.startChecking(); + } + + /** + * Populates the nodesToUpdate property and starts checking which nodes are already + * being updated. + */ + private startChecking() { + // Populate the nodesToUpdate list. + this.nodesToUpdate = []; + this.data.forEach(node => { + this.nodesToUpdate.push({ + key: node.key, + label: node.label ? node.label : this.storageService.getDefaultLabel(node.key), + update: false, + updateProgressInfo: new UpdateProgressInfo(), + }); + + this.nodesToUpdate[this.nodesToUpdate.length - 1].updateProgressInfo.rawMsg = this.translateService.instant('update.starting'); + }); + + // Check which nodes are already being updated. + this.subscription = forkJoin(this.data.map(node => this.nodeService.checkIfUpdating(node.key))).subscribe(nodesBeingUpdated => { + // Save the list of nodes already being updated. + nodesBeingUpdated.forEach((r, i) => { + if (r.running) { + this.indexesAlreadyBeingUpdated.push(i); + this.nodesToUpdate[i].update = true; + } + }); + + if (this.indexesAlreadyBeingUpdated.length === this.data.length) { + // If all nodes are already being updated, call the update function for all of them and + // start showing the progress. + this.update(); + } else { + // Continue to the next step. + this.checkUpdates(); + } + }, (err: OperationError) => { + this.changeState(UpdatingStates.Error); + this.errorText = processServiceError(err).translatableErrorMsg; + }); + } + + /** + * Checks if there are updates available for the nodes which are not currently being updated. + */ + private checkUpdates() { + this.nodesForUpdatesFound = 0; + this.updatesFound = []; + + // Create a list with the nodes to check, ignoring the ones which are already being updated. + const nodesToCheck: NodeToUpdate[] = []; + this.nodesToUpdate.forEach(node => { + if (!node.update) { + nodesToCheck.push(node); + } + }); + + // Check iof there are updates. + this.subscription = forkJoin(nodesToCheck.map(node => this.nodeService.checkUpdate(node.key))).subscribe(versionsResponse => { + // Contains the list of all updates found, without repetitions. + const updates = new Map(); + + // Check the response for each visor. + versionsResponse.forEach((updateInfo, i) => { + if (updateInfo && updateInfo.available) { + // Mark the node for update. + this.nodesForUpdatesFound += 1; + nodesToCheck[i].update = true; + + // Save the name of the update, if it was not found before. + if (!updates.has(updateInfo.current_version + updateInfo.available_version)) { + this.updatesFound.push({ + currentVersion: updateInfo.current_version ? + updateInfo.current_version : this.translateService.instant('common.unknown'), + newVersion: updateInfo.available_version, + }); + + updates.set(updateInfo.current_version + updateInfo.available_version, true); + } + } + }); + + if (this.nodesForUpdatesFound > 0) { + // If the procedure found updates, ask for confirmation before installing them. + this.changeState(UpdatingStates.Asking); + } else { + // If no updates were found and there are no nodes currently being updated, show that + // no updates were found. + if (this.indexesAlreadyBeingUpdated.length === 0) { + this.changeState(UpdatingStates.NoUpdatesFound); + + if (this.data.length === 1) { + this.currentNodeVersion = versionsResponse[0].current_version; + } + } else { + // Continue to the update function to show the progress of the nodes which + // are currently being updated. + this.update(); + } + } + }, (err: OperationError) => { + this.changeState(UpdatingStates.Error); + this.errorText = processServiceError(err).translatableErrorMsg; + }); + } + + /** + * Calls the update API endpoint for all the nodes in the nodesToUpdate list with + * update === true. This makes the update procedure to start, if it was not already + * started and starts showing the progress. + */ + private update() { + this.changeState(UpdatingStates.Updating); + + this.progressSubscriptions = []; + this.nodesToUpdate.forEach((nodeToUpdate, i) => { + if (nodeToUpdate.update) { + // Start the update procedure. + this.progressSubscriptions.push( + this.nodeService.update(nodeToUpdate.key).subscribe(response => { + // Update the progress. + this.updateProgressInfo(response.status, nodeToUpdate.updateProgressInfo); + }, (err: OperationError) => { + // Save the error msg. + nodeToUpdate.updateProgressInfo.errorMsg = processServiceError(err).translatableErrorMsg; + }, () => { + // Indicate that the connection has been closed. + nodeToUpdate.updateProgressInfo.closed = true; + }) + ); + } + }); + } + + /** + * Returns the translatable var that must be used before the list of updates found. + */ + get updateAvailableText(): string { + if (this.data.length === 1) { + // If only one node was requested to be updated. + return 'update.update-available'; + } else { + // If more than one node was requested to be updated, build the var taking into + // account how many nodes will be updated and if there are nodes already being updated. + let response = 'update.update-available'; + + if (this.indexesAlreadyBeingUpdated.length > 0) { + response += '-additional'; + } + + if (this.nodesForUpdatesFound === 1) { + response += '-singular'; + } else { + response += '-plural'; + } + + return response; + } + } + + /** + * Tries to parse a response returned by the backend and updates the values of an + * UpdateProgressInfo instance with the info it was able to recover. + * @param progressMsg Response returned by the backend. + * @param infoToUpdate Instance to update. + */ + private updateProgressInfo(progressMsg: string, infoToUpdate: UpdateProgressInfo) { + // Save basic data. + infoToUpdate.rawMsg = progressMsg; + infoToUpdate.dataParsed = false; + + // Try to get the indexes of parts which are expected to be found in the response. + const downloadingIndex = progressMsg.indexOf('Downloading'); + const initialSpeedIndex = progressMsg.lastIndexOf('('); + const finalSpeedIndex = progressMsg.lastIndexOf(')'); + const initialTimeIndex = progressMsg.lastIndexOf('['); + const finalTimeIndex = progressMsg.lastIndexOf(']'); + const timeSeparatorIndex = progressMsg.lastIndexOf(':'); + const progressPercentageIndex = progressMsg.lastIndexOf('%'); + + // Continue only if all indexes were found. + if ( + downloadingIndex !== -1 && + initialSpeedIndex !== -1 && + finalSpeedIndex !== -1 && + initialTimeIndex !== -1 && + finalTimeIndex !== -1 && + timeSeparatorIndex !== -1 + ) { + // Additional security checks. + let errorFound = false; + if (initialSpeedIndex > finalSpeedIndex) { + errorFound = true; + } + if (initialTimeIndex > timeSeparatorIndex) { + errorFound = true; + } + if (timeSeparatorIndex > finalTimeIndex) { + errorFound = true; + } + if (progressPercentageIndex > initialSpeedIndex || progressPercentageIndex < downloadingIndex) { + errorFound = true; + } + + // Try to get all the data. + try { + if (!errorFound) { + const initialFileIndex = downloadingIndex + 'Downloading'.length + 1; + const finalFileIndex = progressMsg.indexOf(' ', initialFileIndex); + + if (initialFileIndex !== -1 && finalFileIndex !== -1) { + infoToUpdate.fileName = progressMsg.substring(initialFileIndex, finalFileIndex); + } else { + errorFound = true; + } + } + + if (!errorFound) { + infoToUpdate.speed = progressMsg.substring(initialSpeedIndex + 1, finalSpeedIndex); + infoToUpdate.elapsedTime = progressMsg.substring(initialTimeIndex + 1, timeSeparatorIndex); + infoToUpdate.remainingTime = progressMsg.substring(timeSeparatorIndex + 1, finalTimeIndex); + + const initialProgressIndex = progressMsg.lastIndexOf(' ', progressPercentageIndex); + infoToUpdate.progress = Number(progressMsg.substring(initialProgressIndex + 1, progressPercentageIndex)); + } + } catch (e) { + errorFound = true; + } + + if (!errorFound) { + // Indicate that the response was corrently parsed only if all data was obtained. + infoToUpdate.dataParsed = true; + } + } + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + if (this.uiUpdateSubscription) { + this.uiUpdateSubscription.unsubscribe(); + } + + if (this.progressSubscriptions) { + this.progressSubscriptions.forEach(e => e.unsubscribe()); + } + } + + closeModal() { + this.dialogRef.close(); + } + + /** + * Changes the current state of the window. Depending on the new state, it updates some other + * properties to ensure the new state is correctly shown. + */ + private changeState(newState: UpdatingStates) { + this.state = newState; + + // Update the buttons depending on the new state. + if (newState === UpdatingStates.Error) { + this.confirmButtonText = 'common.close'; + this.cancelButtonText = ''; + } else if (newState === UpdatingStates.Asking) { + this.confirmButtonText = 'update.install'; + this.cancelButtonText = 'common.cancel'; + } else if (newState === UpdatingStates.NoUpdatesFound) { + this.confirmButtonText = 'common.close'; + this.cancelButtonText = ''; + } else if (newState === UpdatingStates.Updating) { + this.confirmButtonText = 'common.close'; + this.cancelButtonText = ''; + + // Ensure the changes in the properties are shown in the UI periodically. + this.uiUpdateSubscription = interval(1000).subscribe(() => this.changeDetectorRef.detectChanges()); + } + } +} diff --git a/static/skywire-manager-src/src/app/components/pages/node-list/node-list.component.ts b/static/skywire-manager-src/src/app/components/pages/node-list/node-list.component.ts index ef1067b5d8..4c294e5432 100644 --- a/static/skywire-manager-src/src/app/components/pages/node-list/node-list.component.ts +++ b/static/skywire-manager-src/src/app/components/pages/node-list/node-list.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit, NgZone } from '@angular/core'; -import { Subscription, of, timer, forkJoin, Observable } from 'rxjs'; -import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; +import { Subscription, of, timer, Observable } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; import { Router, ActivatedRoute } from '@angular/router'; import { catchError, mergeMap } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; @@ -15,15 +15,13 @@ import { SnackbarService } from '../../../services/snackbar.service'; import { SidenavService } from 'src/app/services/sidenav.service'; import GeneralUtils from 'src/app/utils/generalUtils'; import { SelectOptionComponent, SelectableOption } from '../../layout/select-option/select-option.component'; -import { processServiceError } from 'src/app/utils/errors'; import { ClipboardService } from 'src/app/services/clipboard.service'; -import { ConfirmationData, ConfirmationComponent } from '../../layout/confirmation/confirmation.component'; import { AppConfig } from 'src/app/app.config'; -import { OperationError } from 'src/app/utils/operation-error'; import { FilterProperties, FilterFieldTypes } from 'src/app/utils/filters'; import { LabeledElementTextComponent } from '../../layout/labeled-element-text/labeled-element-text.component'; import { SortingModes, SortingColumn, DataSorter } from 'src/app/utils/lists/data-sorter'; import { DataFilterer } from 'src/app/utils/lists/data-filterer'; +import { UpdateComponent, NodeData } from '../../layout/update/update.component'; /** * Page for showing the node list. @@ -403,142 +401,20 @@ export class NodeListComponent implements OnInit, OnDestroy { // Updates all visors. updateAll() { if (!this.dataSource || this.dataSource.length === 0) { - this.snackbarService.showError('nodes.update.no-visors'); + this.snackbarService.showError('nodes.no-visors-to-update'); return; } - // Configuration for the confirmation modal window used as the main UI element for the - // updating process. - const confirmationData: ConfirmationData = { - text: 'nodes.update.processing', - headerText: 'nodes.update.title', - confirmButtonText: 'nodes.update.processing-button', - disableDismiss: true, - }; - - // Show the confirmation window in a "loading" state while checking if there are updates. - const config = new MatDialogConfig(); - config.data = confirmationData; - config.autoFocus = false; - config.width = AppConfig.smallModalWidth; - const confirmationDialog = this.dialog.open(ConfirmationComponent, config); - setTimeout(() => confirmationDialog.componentInstance.showProcessing()); - - if (this.updateSubscription) { - this.updateSubscription.unsubscribe(); - } - - // Get the list of all online visors, to check if there are updates available. - const nodesToCheck: string[] = []; - const labelsToCheck: string[] = []; + const nodesData: NodeData[] = []; this.dataSource.forEach(node => { - if (node.online) { - nodesToCheck.push(node.local_pk); - labelsToCheck.push(node.label); - } - }); - - // Keys and labels of all visors with an update available. - const keysWithUpdate: string[] = []; - const labelsWithUpdate: string[] = []; - // How many visors have an update available. - let visorsWithUpdate = 0; - - // Check if there are updates available. - this.updateSubscription = forkJoin(nodesToCheck.map(pk => this.nodeService.checkUpdate(pk))).subscribe(response => { - // Contains the list of all updates found, without repetitions. - const updates = new Map(); - - // Check the response for each visor. - response.forEach((updateInfo, i) => { - if (updateInfo && updateInfo.available) { - visorsWithUpdate += 1; - - // Save the data for calling the update procedure later. - keysWithUpdate.push(nodesToCheck[i]); - labelsWithUpdate.push(labelsToCheck[i]); - - // Save the name of the update, if it was not found before. - if (!updates.has(updateInfo.current_version + updateInfo.available_version)) { - const newVersion = this.translateService.instant('nodes.update.version-change', - { - currentVersion: updateInfo.current_version - ? updateInfo.current_version : this.translateService.instant('common.unknown'), - newVersion: updateInfo.available_version - } - ); - - updates.set(updateInfo.current_version + updateInfo.available_version, newVersion); - } - } - }); - - if (visorsWithUpdate > 0) { - // Text for asking for confirmation before updating. - let newText: string; - if (visorsWithUpdate === 1) { - newText = 'nodes.update.update-available-single'; - } else { - newText = this.translateService.instant('nodes.update.update-available-multiple', {number: visorsWithUpdate}); - } - - const updatesList: string[] = []; - updates.forEach(u => updatesList.push(u)); - - // New configuration for asking for confirmation. - const newConfirmationData: ConfirmationData = { - text: newText, - list: updatesList, - lowerText: 'nodes.update.update-available-confirmation', - headerText: 'nodes.update.title', - confirmButtonText: 'nodes.update.install', - cancelButtonText: 'common.cancel', - }; - - // Ask for confirmation. - setTimeout(() => { - confirmationDialog.componentInstance.showAsking(newConfirmationData); - }); - } else { - // Inform that there are no updates available. - const newText = this.translateService.instant('nodes.update.no-update'); - setTimeout(() => { - confirmationDialog.componentInstance.showDone(null, newText); - }); - } - }, (err: OperationError) => { - err = processServiceError(err); - - // Must wait because the loading state is activated after a frame. - setTimeout(() => { - confirmationDialog.componentInstance.showDone('confirmation.error-header-text', err.translatableErrorMsg); + nodesData.push({ + key: node.local_pk, + label: node.label, }); }); - // React if the user confirms the update. - confirmationDialog.componentInstance.operationAccepted.subscribe(() => { - confirmationDialog.componentInstance.showProcessing(); - - // Update all visors. - this.updateSubscription = this.recursivelyUpdateWallets(keysWithUpdate, labelsWithUpdate).subscribe(response => { - if (response === 0) { - // If everything was ok, show a confirmation. - confirmationDialog.componentInstance.showDone('confirmation.done-header-text', 'nodes.update.done-all'); - } else if (response === visorsWithUpdate) { - // Error if no visor was updated. - confirmationDialog.componentInstance.showDone('confirmation.error-header-text', 'nodes.update.all-failed-error'); - } else { - // Error if only some visors were updated. - confirmationDialog.componentInstance.showDone( - 'confirmation.error-header-text', - this.translateService.instant('nodes.update.some-updated-error', - {failedNumber: response, updatedNumber: visorsWithUpdate - response} - ) - ); - } - }); - }); + UpdateComponent.openDialog(this.dialog, nodesData); } /** diff --git a/static/skywire-manager-src/src/app/components/pages/node/actions/actions.component.ts b/static/skywire-manager-src/src/app/components/pages/node/actions/actions.component.ts index faf5d69588..9198c0c0fa 100644 --- a/static/skywire-manager-src/src/app/components/pages/node/actions/actions.component.ts +++ b/static/skywire-manager-src/src/app/components/pages/node/actions/actions.component.ts @@ -1,5 +1,5 @@ import { Component, AfterViewInit, OnDestroy, Input } from '@angular/core'; -import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; +import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { Subscription } from 'rxjs'; @@ -13,9 +13,9 @@ import { NodeService } from 'src/app/services/node.service'; import { OperationError } from 'src/app/utils/operation-error'; import { processServiceError } from 'src/app/utils/errors'; import { SelectableOption, SelectOptionComponent } from 'src/app/components/layout/select-option/select-option.component'; -import { ConfirmationData, ConfirmationComponent } from 'src/app/components/layout/confirmation/confirmation.component'; -import { AppConfig } from 'src/app/app.config'; import { TranslateService } from '@ngx-translate/core'; +import { UpdateComponent } from 'src/app/components/layout/update/update.component'; +import { StorageService } from 'src/app/services/storage.service'; /** * Component for making the options of the left bar of the nodes page to appear. It does not @@ -51,6 +51,7 @@ export class ActionsComponent implements AfterViewInit, OnDestroy { private sidenavService: SidenavService, private nodeService: NodeService, private translateService: TranslateService, + private storageService: StorageService, ) { } ngAfterViewInit() { @@ -134,83 +135,9 @@ export class ActionsComponent implements AfterViewInit, OnDestroy { } update() { - // Configuration for the confirmation modal window used as the main UI element for the - // updating process. - const confirmationData: ConfirmationData = { - text: 'actions.update.processing', - headerText: 'actions.update.title', - confirmButtonText: 'actions.update.processing-button', - disableDismiss: true, - }; - - // Show the confirmation window in a "loading" state while checking if there are updates. - const config = new MatDialogConfig(); - config.data = confirmationData; - config.autoFocus = false; - config.width = AppConfig.smallModalWidth; - const confirmationDialog = this.dialog.open(ConfirmationComponent, config); - setTimeout(() => confirmationDialog.componentInstance.showProcessing()); - - // Check if there is an update available. - this.updateSubscription = this.nodeService.checkUpdate(NodeComponent.getCurrentNodeKey()).subscribe(response => { - if (response && response.available) { - // New configuration for asking for confirmation. - const newVersion = this.translateService.instant('actions.update.version-change', - { - currentVersion: response.current_version ? response.current_version : this.translateService.instant('common.unknown'), - newVersion: response.available_version - } - ); - const newConfirmationData: ConfirmationData = { - text: 'actions.update.update-available1', - list: [newVersion], - lowerText: 'actions.update.update-available2', - headerText: 'actions.update.title', - confirmButtonText: 'actions.update.install', - cancelButtonText: 'common.cancel', - }; - - // Ask for confirmation. - setTimeout(() => { - confirmationDialog.componentInstance.showAsking(newConfirmationData); - }); - } else if (response) { - // Inform that there are no updates available. - setTimeout(() => { - confirmationDialog.componentInstance.showDone(null, 'actions.update.no-update', [response.current_version]); - }); - } else { - // Inform that there was an error. - setTimeout(() => { - confirmationDialog.componentInstance.showDone('confirmation.error-header-text', 'common.operation-error'); - }); - } - }, (err: OperationError) => { - err = processServiceError(err); - - // Must wait because the loading state is activated after a frame. - setTimeout(() => { - confirmationDialog.componentInstance.showDone('confirmation.error-header-text', err.translatableErrorMsg); - }); - }); - - // React if the user confirm the update. - confirmationDialog.componentInstance.operationAccepted.subscribe(() => { - confirmationDialog.componentInstance.showProcessing(); - - // Update the visor. - this.updateSubscription = this.nodeService.update(NodeComponent.getCurrentNodeKey()).subscribe(response => { - confirmationDialog.componentInstance.data.lowerText = response.status; - }, (err: OperationError) => { - err = processServiceError(err); - - confirmationDialog.componentInstance.showDone('confirmation.error-header-text', err.translatableErrorMsg); - }, - () => { - this.snackbarService.showDone('actions.update.done'); - confirmationDialog.close(); - }); - }); + const labelInfo = this.storageService.getLabelInfo(NodeComponent.getCurrentNodeKey()); + const label = labelInfo ? labelInfo.label : ''; + UpdateComponent.openDialog(this.dialog, [{key: NodeComponent.getCurrentNodeKey(), label: label}]); } terminal() { diff --git a/static/skywire-manager-src/src/app/components/pages/node/node-info/node-info-content/node-info-content.component.html b/static/skywire-manager-src/src/app/components/pages/node/node-info/node-info-content/node-info-content.component.html index cd00977c4f..ed60126033 100644 --- a/static/skywire-manager-src/src/app/components/pages/node/node-info/node-info-content/node-info-content.component.html +++ b/static/skywire-manager-src/src/app/components/pages/node/node-info/node-info-content/node-info-content.component.html @@ -29,10 +29,6 @@ {{ 'node.details.node-info.node-version' | translate }} {{ node.build_info.version ? node.build_info.version : ('common.unknown' | translate) }} - - {{ 'node.details.node-info.app-protocol-version' | translate }} - {{ node.app_protocol_version }} - {{ 'node.details.node-info.time.title' | translate }} {{ ('node.details.node-info.time.' + timeOnline.translationVarName) | translate:{time: timeOnline.elapsedTime} }} diff --git a/static/skywire-manager-src/src/app/services/api.service.ts b/static/skywire-manager-src/src/app/services/api.service.ts index f6c5c91d26..a1e9dcbdce 100644 --- a/static/skywire-manager-src/src/app/services/api.service.ts +++ b/static/skywire-manager-src/src/app/services/api.service.ts @@ -42,6 +42,13 @@ export class ApiService { private readonly apiPrefix = !environment.production && location.protocol.indexOf('http:') !== -1 ? 'http-api/' : 'api/'; + /** + * Similar to apiPrefix, but for web sockets. + */ + private readonly wsApiPrefix = !environment.production ? + (location.protocol.indexOf('http:') !== -1 ? 'ws-api/' : 'wss-api/') : + 'api/'; + constructor( private http: HttpClient, private router: Router, @@ -86,7 +93,7 @@ export class ApiService { */ ws(url: string, body: any = {}): Observable { const wsProtocol = (location.protocol.startsWith('https')) ? 'wss://' : 'ws://'; - const wsUrl = wsProtocol + location.host + '/' + this.apiPrefix + url; + const wsUrl = wsProtocol + location.host + '/' + this.wsApiPrefix + url; const ws = webSocket(wsUrl); ws.next(body); diff --git a/static/skywire-manager-src/src/app/services/node.service.ts b/static/skywire-manager-src/src/app/services/node.service.ts index 99b404825f..eb19148a1f 100644 --- a/static/skywire-manager-src/src/app/services/node.service.ts +++ b/static/skywire-manager-src/src/app/services/node.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; -import { Observable, Subscription, BehaviorSubject, of } from 'rxjs'; +import { Observable, Subscription, BehaviorSubject, of, throwError } from 'rxjs'; import { flatMap, map, mergeMap, delay, tap } from 'rxjs/operators'; import BigNumber from 'bignumber.js'; @@ -614,6 +614,13 @@ export class NodeService { return this.apiService.post(`visors/${nodeKey}/restart`); } + /** + * Checks if there are updates available for a node. + */ + checkIfUpdating(nodeKey: string): Observable { + return this.apiService.get(`visors/${nodeKey}/update/ws/running`); + } + /** * Checks if there are updates available for a node. */ diff --git a/static/skywire-manager-src/src/assets/i18n/en.json b/static/skywire-manager-src/src/assets/i18n/en.json index b6bc81ddbe..be1143218c 100644 --- a/static/skywire-manager-src/src/assets/i18n/en.json +++ b/static/skywire-manager-src/src/assets/i18n/en.json @@ -14,7 +14,8 @@ "logout-error": "Error logging out.", "time-in-ms": "{{ time }}ms", "ok": "Ok", - "unknown": "Unknown" + "unknown": "Unknown", + "close": "Close" }, "labeled-element": { @@ -91,7 +92,6 @@ "dmsg-server": "DMSG server:", "ping": "Ping:", "node-version": "Visor version:", - "app-protocol-version": "App protocol version:", "time": { "title": "Time online:", "seconds": "a few seconds", @@ -150,6 +150,7 @@ "deleted-singular": "1 offline visor removed.", "deleted-plural": "{{ number }} offline visors removed.", "no-offline-nodes": "No offline visors found.", + "no-visors-to-update": "There are no visors to update.", "filter-dialog": { "online": "The visor must be", "label": "The label must contain", @@ -161,24 +162,6 @@ "online": "Online", "offline": "Offline" } - }, - - "update": { - "no-visors": "There are no visors to update. Please wait.", - "title": "Update visors", - "processing": "Looking for updates...", - "processing-button": "Please wait", - "no-update": "Currently, there are no new updates for the visors.", - "update-available-single": "There is an update available for 1 visor:", - "update-available-multiple": "There are updates available for {{ number }} visors:", - "update-available-confirmation": "Click the 'Install updates' button to continue.", - "version-change": "From {{ currentVersion }} to {{ newVersion }}", - "done": "The visor {{ name }} is updated.", - "done-all": "Operation completed. All visors are updated.", - "update-error": "Could not install the update in visor {{ name }}. Please, try again later.", - "all-failed-error": "Could not update any of the visors. Please, try again later.", - "some-updated-error": "{{ updatedNumber }} visors were updated and there were problems for updating {{ failedNumber }}. Please, try again later.", - "install": "Install updates" } }, @@ -246,21 +229,33 @@ "title": "Terminal", "input-start": "Skywire terminal for {{address}}", "error": "Unexpected error while trying to execute the command." - }, - "update": { - "title": "Update", - "processing": "Looking for updates...", - "processing-button": "Please wait", - "no-update": "Currently, there is no update for the visor. The currently installed version is:", - "update-available1": "There is an update available for the visor:", - "update-available2": "Click the 'Install update' button to install it.", - "version-change": "From {{ currentVersion }} to {{ newVersion }}", - "done": "The visor is updated.", - "update-error": "Could not install the update. Please, try again later.", - "install": "Install update" } }, + "update": { + "title": "Update", + "error-title": "Error", + "processing": "Looking for updates...", + "no-update": "There is no update for the visor. The currently installed version is:", + "no-updates": "No new updates were found.", + "already-updating": "Some visors are already being updated:", + "update-available": "The following updates were found:", + "update-available-singular": "The following updates for 1 visor were found:", + "update-available-plural": "The following updates for {{ number }} visors were found:", + "update-available-additional-singular": "The following additional updates for 1 visor were found:", + "update-available-additional-plural": "The following additional updates for {{ number }} visors were found:", + "update-instructions": "Click the 'Install updates' button to continue.", + "updating": "The update operation has been started, you can open this window again for checking the progress:", + "version-change": "From {{ currentVersion }} to {{ newVersion }}", + "downloaded-file-name-prefix": "Downloading: ", + "speed-prefix": "Speed: ", + "time-downloading-prefix": "Time downloading: ", + "time-left-prefix": "Aprox. time left: ", + "starting": "Preparing to update", + "finished": "Status connection finished", + "install": "Install updates" + }, + "apps": { "log": { "title": "Log", diff --git a/static/skywire-manager-src/src/assets/i18n/es.json b/static/skywire-manager-src/src/assets/i18n/es.json index 13b4344458..8f9b850f43 100644 --- a/static/skywire-manager-src/src/assets/i18n/es.json +++ b/static/skywire-manager-src/src/assets/i18n/es.json @@ -14,7 +14,8 @@ "logout-error": "Error cerrando la sesión.", "time-in-ms": "{{ time }}ms", "ok": "Ok", - "unknown": "Desconocido" + "unknown": "Desconocido", + "close": "Cerrar" }, "labeled-element": { @@ -91,7 +92,6 @@ "dmsg-server": "Servidor DMSG:", "ping": "Ping:", "node-version": "Versión del visor:", - "app-protocol-version": "Versión del protocolo de app:", "time": { "title": "Tiempo online:", "seconds": "unos segundos", @@ -150,6 +150,7 @@ "deleted-singular": "1 visor offline removido.", "deleted-plural": "{{ number }} visores offline removidos.", "no-offline-nodes": "No se encontraron visores offline.", + "no-visors-to-update": "No hay visores para actualizar.", "filter-dialog": { "online": "El visor debe estar", "label": "La etiqueta debe contener", @@ -161,24 +162,6 @@ "online": "Online", "offline": "Offline" } - }, - - "update": { - "no-visors": "No hay visores para actualizar. Por favor espere.", - "title": "Actualizar visores", - "processing": "Buscando actualizaciones...", - "processing-button": "Por favor espere", - "no-update": "Actualmente no hay nuevas actualizaciones para los visores.", - "update-available-single": "Hay una actualización disponible para 1 visor:", - "update-available-multiple": "Hay actualizaciones disponibles para {{ number }} visores:", - "update-available-confirmation": "Haga clic en el botón 'Instalar actualizaciones' para continuar.", - "version-change": "De {{ currentVersion }} a {{ newVersion }}", - "done": "El visor {{ name }} está siendo actualizado.", - "done-all": "Operación completada. Todos los visores están siendo actualizandos.", - "update-error": "No se pudo instalar la actualización en el visor {{name}}. Por favor, inténtelo de nuevo más tarde.", - "all-failed-error": "No se pudo actualizar ninguno de los visores. Por favor, inténtelo de nuevo más tarde.", - "some-updated-error": "{{ updatedNumber }} visores fueron actualizados y hubo problemas para actualizar {{ failedNumber }}. Por favor, inténtelo de nuevo más tarde.", - "install": "Instalar actualizaciones" } }, @@ -246,21 +229,33 @@ "title": "Terminal", "input-start": "Terminal de Skywire para {{address}}", "error": "Error inesperado mientras se intentaba ejecutar el comando." - }, - "update": { - "title": "Actualizar", - "processing": "Buscando actualizaciones...", - "processing-button": "Por favor espere", - "no-update": "Actualmente no hay ninguna actualización para el visor. La versión instalada actualmente es:", - "update-available1": "Hay una actualización disponible para el visor:", - "update-available2": "Haga clic en el botón 'Instalar actualización' para instalarla.", - "version-change": "De {{ currentVersion }} a {{ newVersion }}", - "done": "El visor está siendo actualizado.", - "update-error": "No se pudo instalar la actualización. Por favor inténtelo nuevamente luego.", - "install": "Instalar actualización" } }, + "update": { + "title": "Actualizar", + "error-title": "Error", + "processing": "Buscando actualizaciones...", + "no-update": "No hay ninguna actualización para el visor. La versión instalada actualmente es:", + "no-updates": "No se encontraron nuevas actualizaciones.", + "already-updating": "Algunos visores ya están siendo actualizandos:", + "update-available": "Las siguientes actualizaciones fueron encontradas:", + "update-available-singular": "Las siguientes actualizaciones para 1 visor fueron encontradas:", + "update-available-plural": "Las siguientes actualizaciones para {{ number }} visores fueron encontradas:", + "update-available-additional-singular": "Las siguientes actualizaciones adicionales para 1 visor fueron encontradas:", + "update-available-additional-plural": "Las siguientes actualizaciones adicionales para {{ number }} visores fueron encontradas:", + "update-instructions": "Haga clic en el botón 'Instalar actualizaciones' para continuar.", + "updating": "La operación de actualización se ha iniciado, puede abrir esta ventana nuevamente para verificar el progreso:", + "version-change": "De {{ currentVersion }} a {{ newVersion }}", + "downloaded-file-name-prefix": "Descargando: ", + "speed-prefix": "Velocidad: ", + "time-downloading-prefix": "Tiempo descargando: ", + "time-left-prefix": "Tiempo aprox. faltante: ", + "starting": "Preparando para actualizar", + "finished": "Conexión de estado terminada", + "install": "Instalar actualizaciones" + }, + "apps": { "log": { "title": "Log", diff --git a/static/skywire-manager-src/src/assets/i18n/es_base.json b/static/skywire-manager-src/src/assets/i18n/es_base.json index c9abb229b5..be1143218c 100644 --- a/static/skywire-manager-src/src/assets/i18n/es_base.json +++ b/static/skywire-manager-src/src/assets/i18n/es_base.json @@ -14,7 +14,8 @@ "logout-error": "Error logging out.", "time-in-ms": "{{ time }}ms", "ok": "Ok", - "unknown": "Unknown" + "unknown": "Unknown", + "close": "Close" }, "labeled-element": { @@ -91,7 +92,6 @@ "dmsg-server": "DMSG server:", "ping": "Ping:", "node-version": "Visor version:", - "app-protocol-version": "App protocol version:", "time": { "title": "Time online:", "seconds": "a few seconds", @@ -150,6 +150,7 @@ "deleted-singular": "1 offline visor removed.", "deleted-plural": "{{ number }} offline visors removed.", "no-offline-nodes": "No offline visors found.", + "no-visors-to-update": "There are no visors to update.", "filter-dialog": { "online": "The visor must be", "label": "The label must contain", @@ -161,24 +162,6 @@ "online": "Online", "offline": "Offline" } - }, - - "update": { - "no-visors": "There are no visors to update. Please wait.", - "title": "Update visors", - "processing": "Looking for updates...", - "processing-button": "Please wait", - "no-update": "Currently, there are no new updates for the visors.", - "update-available-single": "There is an update available for 1 visor:", - "update-available-multiple": "There are updates available for {{ number }} visors:", - "update-available-confirmation": "Click the 'Install updates' button to continue.", - "version-change": "From {{ currentVersion }} to {{ newVersion }}", - "done": "The visor {{ name }} is being updated.", - "done-all": "Operation completed. All visors are being updated.", - "update-error": "Could not install the update in visor {{ name }}. Please, try again later.", - "all-failed-error": "Could not update any of the visors. Please, try again later.", - "some-updated-error": "{{ updatedNumber }} visors were updated and there were problems for updating {{ failedNumber }}. Please, try again later.", - "install": "Install updates" } }, @@ -246,21 +229,33 @@ "title": "Terminal", "input-start": "Skywire terminal for {{address}}", "error": "Unexpected error while trying to execute the command." - }, - "update": { - "title": "Update", - "processing": "Looking for updates...", - "processing-button": "Please wait", - "no-update": "Currently, there is no update for the visor. The currently installed version is:", - "update-available1": "There is an update available for the visor:", - "update-available2": "Click the 'Install update' button to install it.", - "version-change": "From {{ currentVersion }} to {{ newVersion }}", - "done": "The visor is being updated.", - "update-error": "Could not install the update. Please, try again later.", - "install": "Install update" } }, + "update": { + "title": "Update", + "error-title": "Error", + "processing": "Looking for updates...", + "no-update": "There is no update for the visor. The currently installed version is:", + "no-updates": "No new updates were found.", + "already-updating": "Some visors are already being updated:", + "update-available": "The following updates were found:", + "update-available-singular": "The following updates for 1 visor were found:", + "update-available-plural": "The following updates for {{ number }} visors were found:", + "update-available-additional-singular": "The following additional updates for 1 visor were found:", + "update-available-additional-plural": "The following additional updates for {{ number }} visors were found:", + "update-instructions": "Click the 'Install updates' button to continue.", + "updating": "The update operation has been started, you can open this window again for checking the progress:", + "version-change": "From {{ currentVersion }} to {{ newVersion }}", + "downloaded-file-name-prefix": "Downloading: ", + "speed-prefix": "Speed: ", + "time-downloading-prefix": "Time downloading: ", + "time-left-prefix": "Aprox. time left: ", + "starting": "Preparing to update", + "finished": "Status connection finished", + "install": "Install updates" + }, + "apps": { "log": { "title": "Log", diff --git a/static/skywire-manager-src/src/assets/scss/_variables.scss b/static/skywire-manager-src/src/assets/scss/_variables.scss index d018ea55af..caa38bf60b 100644 --- a/static/skywire-manager-src/src/assets/scss/_variables.scss +++ b/static/skywire-manager-src/src/assets/scss/_variables.scss @@ -12,6 +12,7 @@ $blue-dark-Background2: #0a1421; $red: #DA3439; $green: #2ECC54; +$yellow: #d48b05; $light-gray: #777; $lighter-gray: #999;