Implement write validation support

* Extend ProgressButton to support a striped progress bar

This feature will be used to implement the burn validation step.

* Implement alert-ribbon CSS component

This component will be used to inform an error situation to the user
during the burn/check processes.

* Add "Enable write validation on success" setting

* Implement write validation support

Signed-off-by: Juan Cruz Viotti <jviottidc@gmail.com>
Fixes: https://github.com/resin-io/etcher/issues/45
This commit is contained in:
Juan Cruz Viotti 2016-04-06 21:53:35 -04:00
parent e8516b1727
commit 3392a5eca1
14 changed files with 320 additions and 51 deletions

View File

@ -1254,7 +1254,7 @@ mark,
.text-right, .section-header {
text-align: right; }
.text-center {
.text-center, .alert, .alert-ribbon {
text-align: center; }
.text-justify {
@ -3025,28 +3025,28 @@ fieldset[disabled] a.progress-button {
.btn-warning {
color: #fff;
background-color: #f0ad4e;
border-color: #eea236; }
background-color: #e99852;
border-color: #e68b3b; }
.btn-warning:focus, .btn-warning.focus {
color: #fff;
background-color: #ec971f;
border-color: #985f0d; }
background-color: #e37d25;
border-color: #904c12; }
.btn-warning:hover {
color: #fff;
background-color: #ec971f;
border-color: #d58512; }
background-color: #e37d25;
border-color: #cb6c1a; }
.btn-warning:active, .btn-warning.active,
.open > .btn-warning.dropdown-toggle {
color: #fff;
background-color: #ec971f;
border-color: #d58512; }
background-color: #e37d25;
border-color: #cb6c1a; }
.btn-warning:active:hover, .btn-warning:active:focus, .btn-warning:active.focus, .btn-warning.active:hover, .btn-warning.active:focus, .btn-warning.active.focus,
.open > .btn-warning.dropdown-toggle:hover,
.open > .btn-warning.dropdown-toggle:focus,
.open > .btn-warning.dropdown-toggle.focus {
color: #fff;
background-color: #d58512;
border-color: #985f0d; }
background-color: #cb6c1a;
border-color: #904c12; }
.btn-warning:active, .btn-warning.active,
.open > .btn-warning.dropdown-toggle {
background-image: none; }
@ -3054,10 +3054,10 @@ fieldset[disabled] a.progress-button {
fieldset[disabled] .btn-warning:hover,
fieldset[disabled] .btn-warning:focus,
fieldset[disabled] .btn-warning.focus {
background-color: #f0ad4e;
border-color: #eea236; }
background-color: #e99852;
border-color: #e68b3b; }
.btn-warning .badge {
color: #f0ad4e;
color: #e99852;
background-color: #fff; }
.btn-danger {
@ -3124,7 +3124,7 @@ fieldset[disabled] a.progress-button {
line-height: 1.33333;
border-radius: 6px; }
.btn-sm, .btn-group-sm > .btn, .btn-group-sm > .progress-button {
.btn-sm, .btn-group-sm > .btn, .btn-group-sm > .progress-button, .alert-ribbon .btn-link {
padding: 5px 10px;
font-size: 12px;
line-height: 1.5;
@ -3979,7 +3979,7 @@ tbody.collapse.in {
.navbar-btn {
margin-top: 9px;
margin-bottom: 9px; }
.navbar-btn.btn-sm, .btn-group-sm > .navbar-btn.btn, .btn-group-sm > .navbar-btn.progress-button {
.navbar-btn.btn-sm, .btn-group-sm > .navbar-btn.btn, .btn-group-sm > .navbar-btn.progress-button, .alert-ribbon .navbar-btn.btn-link {
margin-top: 10px;
margin-bottom: 10px; }
.navbar-btn.btn-xs, .btn-group-xs > .navbar-btn.btn, .btn-group-xs > .navbar-btn.progress-button {
@ -4305,9 +4305,9 @@ a.label:hover, a.label:focus {
background-color: #31b0d5; }
.label-warning {
background-color: #f0ad4e; }
background-color: #e99852; }
.label-warning[href]:hover, .label-warning[href]:focus {
background-color: #ec971f; }
background-color: #e37d25; }
.label-danger {
background-color: #d9534f; }
@ -4414,25 +4414,25 @@ a.thumbnail:focus,
a.thumbnail.active {
border-color: #ddd; }
.alert {
padding: 15px;
.alert, .alert-ribbon {
padding: 13px;
margin-bottom: 18px;
border: 1px solid transparent;
border-radius: 4px; }
.alert h4 {
.alert h4, .alert-ribbon h4 {
margin-top: 0;
color: inherit; }
.alert .alert-link {
.alert .alert-link, .alert-ribbon .alert-link {
font-weight: bold; }
.alert > p,
.alert > ul {
.alert > p, .alert-ribbon > p,
.alert > ul, .alert-ribbon > ul {
margin-bottom: 0; }
.alert > p + p {
.alert > p + p, .alert-ribbon > p + p {
margin-top: 5px; }
.alert-dismissable,
.alert-dismissible {
padding-right: 35px; }
padding-right: 33px; }
.alert-dismissable .close,
.alert-dismissible .close {
position: relative;
@ -4459,13 +4459,13 @@ a.thumbnail.active {
color: #245269; }
.alert-warning {
background-color: #fcf8e3;
background-color: #e99852;
border-color: #faebcc;
color: #8a6d3b; }
color: #fff; }
.alert-warning hr {
border-top-color: #f7e1b5; }
.alert-warning .alert-link {
color: #66512c; }
color: #e6e6e6; }
.alert-danger {
background-color: #f2dede;
@ -4540,7 +4540,7 @@ a.thumbnail.active {
background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); }
.progress-bar-warning {
background-color: #f0ad4e; }
background-color: #e99852; }
.progress-striped .progress-bar-warning {
background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
@ -5915,6 +5915,10 @@ html {
.modal-backdrop.in {
opacity: 0; }
.alert, .alert-ribbon {
border: none;
border-radius: 2px; }
/*
* Copyright 2016 Resin.io
*
@ -6032,7 +6036,7 @@ html {
.btn-brick {
min-width: 170px; }
.btn-sm, .btn-group-sm > .btn, .btn-group-sm > .progress-button {
.btn-sm, .btn-group-sm > .btn, .btn-group-sm > .progress-button, .alert-ribbon .btn-link {
font-size: 10px;
padding: 4px 12px; }
@ -6143,6 +6147,13 @@ button.btn:focus, button.progress-button:focus {
* This is useful to determine if the progress bar is paused from the point of view
* of the styling.
*
* You can optionally pass the `.progress-button--striped` modified to get a striped
* progress bar.
*
* The stripe implementation idea was taken from:
*
* https://css-tricks.com/css3-progress-bars/
*
* Usage:
*
* <button class="progress-button progress-button--primary" percentage="50" active="true">
@ -6153,11 +6164,14 @@ button.btn:focus, button.progress-button:focus {
.progress-button--primary .progress-button__bar {
background: #6ca1e0; }
.progress-button--primary.progress-button--striped .progress-button__bar:after {
background-image: -webkit-gradient(linear, 0 0, 100% 100%, color-stop(0.25, #3b679b), color-stop(0.25, #5c93d6), color-stop(0.5, #5c93d6), color-stop(0.5, #3b679b), color-stop(0.75, #3b679b), color-stop(0.75, #5c93d6), to(#5c93d6)); }
.progress-button[percentage="100"][active="false"] .progress-button__bar {
background-color: #5cb85c; }
.progress-button[percentage="100"][active="true"] .progress-button__bar {
background-color: #f0ad4e; }
background-color: #e99852; }
.progress-button[active="true"] {
pointer-events: none; }
@ -6172,7 +6186,24 @@ button.btn:focus, button.progress-button:focus {
top: 0;
width: 0;
height: 100%;
transition: width 0.3s, opacity 0.3s; }
transition: width 0.3s; }
.progress-button--striped .progress-button__bar:after {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-size: 20px 20px;
animation: progress-button-stripes 1s linear infinite;
overflow: hidden; }
@keyframes progress-button-stripes {
0% {
background-position: 0 0; }
100% {
background-position: 20px 20px; } }
/*
* Copyright 2016 Resin.io
@ -6237,6 +6268,51 @@ button.btn:focus, button.progress-button:focus {
.modal-body .list-group-item:first-child {
border-top: none; }
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.alert-ribbon {
width: 60%;
position: fixed;
left: 0;
right: 0;
margin: 0 auto;
border-top-left-radius: 0;
border-top-right-radius: 0;
top: -100%;
transition: top 0.5s; }
.alert-ribbon > .glyphicon:first-child, .alert-ribbon > .tick:first-child {
margin-right: 5px; }
.alert-ribbon > .glyphicon:last-child, .alert-ribbon > .tick:last-child {
margin-left: 5px; }
.alert-ribbon .btn-link {
padding: 0;
font-size: inherit;
vertical-align: baseline;
border-radius: 0;
border-bottom: 1px solid; }
.alert-ribbon.alert-warning .btn-link {
border-color: #f7dbc3;
color: #fff; }
.alert-ribbon.alert-warning .btn-link:hover {
color: #e6e6e6;
border-color: #f2c096; }
.alert-ribbon--open {
top: 0; }
hero-icon[disabled] .caption {
color: #787c7f; }

View File

@ -54,6 +54,7 @@ const app = angular.module('Etcher', [
// Models
'Etcher.Models.SelectionState',
'Etcher.Models.Settings',
// Components
'Etcher.Components.ProgressButton',
@ -87,6 +88,7 @@ app.controller('AppController', function(
NotifierService,
DriveScannerService,
SelectionStateModel,
SettingsModel,
ImageWriterService,
AnalyticsService,
DriveSelectorService,
@ -96,6 +98,8 @@ app.controller('AppController', function(
this.selection = SelectionStateModel;
this.writer = ImageWriterService;
this.scanner = DriveScannerService;
this.settings = SettingsModel.data;
this.success = true;
// This catches the case where the user enters
// the settings screen when a burn finished
@ -114,7 +118,7 @@ app.controller('AppController', function(
}
NotifierService.subscribe($scope, 'image-writer:state', function(state) {
AnalyticsService.log(`Progress: ${state.progress}% at ${state.speed} MB/s`);
AnalyticsService.log(`Progress (${state.type}): ${state.progress}% at ${state.speed} MB/s`);
WindowProgressService.set(state.progress);
});
@ -202,6 +206,16 @@ app.controller('AppController', function(
AnalyticsService.logEvent('Reselect drive');
};
this.restartAfterFailure = function() {
self.selection.clear({
preserveImage: true
});
self.writer.resetState();
self.success = true;
AnalyticsService.logEvent('Restart after failure');
};
this.burn = function(image, drive) {
// Stop scanning drives when burning
@ -213,9 +227,18 @@ app.controller('AppController', function(
device: drive.device
});
return self.writer.burn(image, drive).then(function() {
AnalyticsService.logEvent('Done');
$state.go('success');
return self.writer.burn(image, drive).then(function(success) {
// TODO: Find a better way to manage burn/check
// success/error state than a global boolean flag.
self.success = success;
if (self.success) {
AnalyticsService.logEvent('Done');
$state.go('success');
} else {
AnalyticsService.logEvent('Burn error');
}
})
.catch(dialog.showError)
.finally(WindowProgressService.clear);

View File

@ -22,7 +22,7 @@
*
* Example:
*
* <progress-button percentage="{{ 40 }}">My Progress Button</progress-button>
* <progress-button percentage="{{ 40 }}" striped>My Progress Button</progress-button>
*/
module.exports = function() {
@ -32,7 +32,8 @@ module.exports = function() {
replace: true,
transclude: true,
scope: {
percentage: '='
percentage: '=',
striped: '@'
}
};
};

View File

@ -29,6 +29,13 @@
* This is useful to determine if the progress bar is paused from the point of view
* of the styling.
*
* You can optionally pass the `.progress-button--striped` modified to get a striped
* progress bar.
*
* The stripe implementation idea was taken from:
*
* https://css-tricks.com/css3-progress-bars/
*
* Usage:
*
* <button class="progress-button progress-button--primary" percentage="50" active="true">
@ -37,6 +44,9 @@
* </button>
*/
$progress-button-stripes-width: 20px;
$progress-button-stripes-animation-duration: 1s;
.progress-button {
@extend .btn;
}
@ -47,6 +57,20 @@
.progress-button__bar {
background: lighten($brand-primary, 5);
}
$progress-button-stripes-background-color: desaturate($brand-primary, 5%);
$progress-button-stripes-color: desaturate(darken($brand-primary, 18%), 20%);
&.progress-button--striped .progress-button__bar:after {
background-image: -webkit-gradient(linear, 0 0, 100% 100%,
color-stop(0.25, $progress-button-stripes-color),
color-stop(0.25, $progress-button-stripes-background-color),
color-stop(0.50, $progress-button-stripes-background-color),
color-stop(0.50, $progress-button-stripes-color),
color-stop(0.75, $progress-button-stripes-color),
color-stop(0.75, $progress-button-stripes-background-color),
to($progress-button-stripes-background-color));
}
}
.progress-button[percentage="100"][active="false"] .progress-button__bar {
@ -77,6 +101,32 @@
height: 100%;
// Subtle progress bar animation
transition: width 0.3s, opacity 0.3s;
transition: width 0.3s;
}
.progress-button--striped .progress-button__bar:after {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-size: $progress-button-stripes-width $progress-button-stripes-width;
animation: progress-button-stripes $progress-button-stripes-animation-duration linear infinite;
overflow: hidden;
}
@keyframes progress-button-stripes {
0% {
background-position: 0 0;
}
100% {
background-position: $progress-button-stripes-width $progress-button-stripes-width;
}
}

View File

@ -1,4 +1,7 @@
<button class="progress-button progress-button--primary">
<span class="progress-button__content" ng-transclude></span>
<span class="progress-button__bar" ng-style="{ width: percentage + '%' }"></span>
<button class="progress-button progress-button--primary"
ng-class="{
'progress-button--striped': striped && striped != 'false'
}">
<span class="progress-button__content" ng-transclude></span>
<span class="progress-button__bar" ng-style="{ width: percentage + '%' }"></span>
</button>

View File

@ -35,7 +35,8 @@ SettingsModel.service('SettingsModel', function($localStorage) {
*/
this.data = $localStorage.$default({
errorReporting: true,
unmountOnSuccess: true
unmountOnSuccess: true,
validateWriteOnSuccess: true
});
});

View File

@ -154,6 +154,7 @@ imageWriter.service('ImageWriterService', function($q, $timeout, SettingsModel,
$timeout(function() {
self.state = {
type: state.type,
progress: Math.floor(state.percentage),
// Transform bytes to megabytes preserving only two decimal places

View File

@ -14,4 +14,11 @@
<span>Enable auto-unmounting on success</span>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="settings.storage.validateWriteOnSuccess">
<span>Enable write validation on success</span>
</label>
</div>
</div>

View File

@ -50,17 +50,39 @@
<span class="badge space-top-medium" ng-disabled="!app.selection.hasImage() || !app.selection.hasDrive()">3</span>
<div class="space-vertical-large">
<progress-button class="btn-brick" percentage="app.writer.state.progress" ng-attr-active="{{ app.writer.isBurning() }}"
<progress-button class="btn-brick"
percentage="app.writer.state.progress"
striped="{{ app.writer.state.type == 'check' }}"
ng-attr-active="{{ app.writer.isBurning() }}"
ng-show="app.success"
ng-click="app.burn(app.selection.getImage(), app.selection.getDrive())"
ng-disabled="!app.selection.hasImage() || !app.selection.hasDrive()">
<span ng-show="app.writer.state.progress == 100 && app.writer.isBurning()">Finishing...</span>
<span ng-show="app.writer.state.progress == 0 && !app.writer.isBurning()">Burn!</span>
<span ng-show="app.writer.state.progress == 0 && app.writer.isBurning() && !app.writer.state.speed">Starting...</span>
<span ng-show="app.writer.state.speed && app.writer.state.progress != 100"
<span ng-show="app.writer.state.speed && app.writer.state.progress != 100 && app.writer.state.type != 'check'"
ng-bind="app.writer.state.progress + '% '"></span>
<span ng-show="app.writer.state.speed && app.writer.state.progress != 100 && app.writer.state.type == 'check'"
ng-bind="app.writer.state.progress + '% Validating...'"></span>
</progress-button>
<p class="step-footer" ng-bind="app.writer.state.speed.toFixed(2) + ' MB/s'" ng-show="app.writer.state.speed && app.writer.state.progress != 100"></p>
<div class="alert-ribbon alert-warning" ng-class="{ 'alert-ribbon--open': !app.success }">
<span class="glyphicon glyphicon-warning-sign"></span>
<span ng-show="app.settings.validateWriteOnSuccess">
Your removable drive did not pass validation check.<br>Please insert another one and <button class="btn btn-link" ng-click="app.restartAfterFailure()">try again</button>
</span>
<span ng-hide="app.settings.validateWriteOnSuccess">
Oops, seems something went wrong. Click <button class="btn btn-link" ng-click="app.restartAfterFailure()">here</button> to retry
</span>
</div>
<button class="btn btn-warning btn-brick" ng-hide="app.success" ng-click="app.restartAfterFailure()">
<span class="glyphicon glyphicon-repeat"></span> Retry
</button>
<p class="step-footer"
ng-bind="app.writer.state.speed.toFixed(2) + ' MB/s'"
ng-show="app.writer.state.speed && app.writer.state.progress != 100 && app.writer.state.type == 'write'"></p>
</div>
</div>
</div>

View File

@ -0,0 +1,68 @@
/*
* Copyright 2016 Resin.io
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.alert-ribbon {
@extend .alert;
width: 60%;
position: fixed;
left: 0;
right: 0;
margin: 0 auto;
border-top-left-radius: 0;
border-top-right-radius: 0;
// Animate appearance
top: -100%;
transition: top 0.5s;
// Align alert icons a bit better
> .glyphicon {
&:first-child {
margin-right: 5px;
}
&:last-child {
margin-left: 5px;
}
}
.btn-link {
@extend .btn-sm;
padding: 0;
font-size: inherit;
vertical-align: baseline;
border-radius: 0;
border-bottom: 1px solid;
}
&.alert-warning .btn-link {
border-color: lighten($alert-warning-bg, 25%);
color: $alert-warning-text;
&:hover {
color: darken($alert-warning-text, 10%);
border-color: lighten($alert-warning-bg, 15%);
}
}
}
.alert-ribbon--open {
top: 0;
}

View File

@ -28,6 +28,10 @@ $link-color: $gray-light;
$link-hover-decoration: none;
$btn-default-bg: #ececec;
$btn-default-color: #b3b3b3;
$brand-warning: rgb(233, 152, 82);
$alert-warning-bg: $brand-warning;
$alert-warning-text: #fff;
$alert-padding: 13px;
@import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap";
@ -39,6 +43,7 @@ $btn-default-color: #b3b3b3;
@import "./components/tick";
@import "../browser/components/progress-button/styles/progress-button";
@import "./components/modal";
@import "./components/alert-ribbon";
hero-icon[disabled] .caption {
color: $color-disabled;

View File

@ -36,3 +36,10 @@ html {
.modal-backdrop.in {
opacity: 0;
}
.alert {
@extend .text-center;
border: none;
border-radius: 2px;
}

View File

@ -70,15 +70,18 @@ exports.getImageStream = function(image) {
* @param {Object} drive - drive
* @param {Object} options - options
* @param {Boolean} [options.unmountOnSuccess=false] - unmount on success
* @param {Boolean} [options.validateWriteOnSuccess=false] - validate write on success
* @param {Function} onProgress - on progress callback (state)
*
* @fulfil {Boolean} - whether the operation was successful
* @returns {Promise}
*
* @example
* writer.writeImage('path/to/image.img', {
* device: '/dev/disk2'
* }, {
* unmountOnSuccess: true
* unmountOnSuccess: true,
* validateWriteOnSuccess: true
* }, function(state) {
* console.log(state.percentage);
* }).then(function() {
@ -89,14 +92,16 @@ exports.writeImage = function(image, drive, options, onProgress) {
return umount.umountAsync(drive.device).then(function() {
return exports.getImageStream(image);
}).then(function(stream) {
return imageWrite.write(drive.device, stream);
return imageWrite.write(drive.device, stream, {
check: options.validateWriteOnSuccess
});
}).then(function(writer) {
return new Bluebird(function(resolve, reject) {
writer.on('progress', onProgress);
writer.on('error', reject);
writer.on('done', resolve);
});
}).then(function() {
}).tap(function() {
if (!options.unmountOnSuccess) {
return;
}

View File

@ -84,7 +84,7 @@
"lodash": "^4.5.1",
"ngstorage": "^0.3.10",
"open": "0.0.5",
"resin-image-write": "^2.0.5",
"resin-image-write": "^3.0.2",
"resin-zip-image": "^1.1.2",
"sudo-prompt": "^2.2.0",
"trackjs": "^2.1.16",