Restore UI (#823)

* server: added restore api
* htmlui: restore UI
This commit is contained in:
Jarek Kowalski
2021-02-11 02:08:47 -08:00
committed by GitHub
parent c964e244f0
commit 504238df7a
10 changed files with 321 additions and 14 deletions

View File

@@ -56,6 +56,10 @@ body {
padding: 10px;
}
.padded-top {
padding-top: 10px;
}
.debug-json {
font-size: 60%;
}
@@ -124,3 +128,15 @@ div.tab-body {
.loglevel-3 { color: rgb(169, 112, 5); font-weight: bold;} /* warning */
.loglevel-4 { color: red; font-weight: bold; } /* error */
.loglevel-5 { color: red; font-weight: bold; } /* fatal */
.counter-badge {
font-size: 100%;
}
.page-title {
margin-left: 10px;
font-weight: bold;
height: 40px;
font-size: 125%;
vertical-align: text-bottom;
}

View File

@@ -6,6 +6,7 @@ import Nav from 'react-bootstrap/Nav';
import Navbar from 'react-bootstrap/Navbar';
import { BrowserRouter as Router, NavLink, Redirect, Route, Switch } from 'react-router-dom';
import './App.css';
import { BeginRestore } from './BeginRestore';
import { DirectoryObject } from "./DirectoryObject";
import logo from './kopia-flat.svg';
import { PoliciesTable } from "./PoliciesTable";
@@ -67,6 +68,7 @@ function App() {
<Container fluid>
<Switch>
<Route path="/snapshots/single-source/" component={SnapshotsTable} />
<Route path="/snapshots/dir/:oid/restore" component={BeginRestore} />
<Route path="/snapshots/dir/:oid" component={DirectoryObject} />
<Route path="/snapshots" component={SourcesTable} />
<Route path="/policies" component={PoliciesTable} />

133
htmlui/src/BeginRestore.js Normal file
View File

@@ -0,0 +1,133 @@
import axios from 'axios';
import React, { Component } from 'react';
import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form';
import { Link } from "react-router-dom";
import { handleChange, RequiredBoolean, RequiredField, validateRequiredFields } from './forms';
import { GoBackButton } from './uiutil';
export class BeginRestore extends Component {
constructor(props) {
super();
this.state = {
incremental: true,
continueOnErrors: false,
restoreOwnership: true,
restorePermissions: true,
restoreModTimes: true,
uncompressedZip: true,
overwriteFiles: false,
overwriteDirectories: false,
overwriteSymlinks: false,
ignorePermissionErrors: true,
restoreTask: "",
};
this.handleChange = handleChange.bind(this);
this.start = this.start.bind(this);
}
start(e) {
e.preventDefault();
if (!validateRequiredFields(this, ["destination"])) {
return;
}
const dst = (this.state.destination + "");
let req = {
root: this.props.match.params.oid,
options: {
incremental: this.state.incremental,
ignoreErrors: this.state.continueOnErrors,
},
}
if (dst.endsWith(".zip")) {
req.zipFile = dst;
req.uncompressedZip = this.state.uncompressedZip;
} else if (dst.endsWith(".tar")) {
req.tarFile = dst;
} else {
req.fsOutput = {
targetPath: dst,
skipOwners: !this.state.restoreOwnership,
skipPermissions: !this.state.restorePermissions,
skipTimes: !this.state.restoreModTimes,
ignorePermissionErrors: this.state.ignorePermissionErrors,
overwriteFiles: this.state.overwriteFiles,
overwriteDirectories: this.state.overwriteDirectories,
overwriteSymlinks: this.state.overwriteSymlinks,
}
}
axios.post('/api/v1/restore', req).then(result => {
this.setState({
restoreTask: result.data.id,
})
this.props.history.replace("/tasks/" + result.data.id);
}).catch(error => {
if (error.response.data) {
alert(JSON.stringify(error.response.data));
} else {
alert('failed');
}
});
}
render() {
if (this.state.restoreTask) {
return <p>
<GoBackButton onClick={this.props.history.goBack} />
<Link replace="true" to={"/tasks/" + this.state.restoreTask}>Go To Restore Task</Link>.
</p>;
}
return <div class="padded-top">
<GoBackButton onClick={this.props.history.goBack} />&nbsp;<span className="page-title">Restore</span>
<hr/>
<Form onSubmit={this.start}>
<Form.Row>
{RequiredField(this, "Destination", "destination", {
autoFocus: true,
placeholder: "enter destination path",
},
"You can also restore to a .zip or .tar file by providing the appropriate extension.")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Skip previously restored files and symlinks", "incremental")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Continue on Errors", "continueOnErrors", "When a restore error occurs, attempt to continue instead of failing fast.")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Restore File Ownership", "restoreOwnership")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Restore File Permissions", "restorePermissions")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Restore File Modification Time", "restoreModTimes")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Overwrite Files", "overwriteFiles")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Overwrite Directories", "overwriteDirectories")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Overwrite Symbolic Links", "overwriteSymlinks")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Disable ZIP compression", "uncompressedZip", "Do not compress when restoring to a ZIP file (faster).")}
</Form.Row>
<Form.Row>
<Button variant="primary" type="submit" data-testid="submit-button">Begin Restore</Button>
</Form.Row>
</Form>
</div>;
}
}

View File

@@ -131,6 +131,8 @@ export class DirectoryObject extends Component {
</> : <>
<Button size="sm" variant="primary" onClick={this.mount} >Mount</Button>
</>}
&nbsp;
<Button size="sm" variant="info" href={"/snapshots/dir/" + this.props.match.params.oid +"/restore"}>Restore...</Button>
</Row>
<hr/>
<Row>

View File

@@ -117,7 +117,7 @@ export class TaskDetails extends Component {
break;
}
return <Badge variant={variant}>{label}: {formatted}</Badge>
return <Badge className="counter-badge" variant={variant}>{label}: {formatted}</Badge>
}
render() {
@@ -169,7 +169,7 @@ export class TaskDetails extends Component {
<hr/>
<Form.Row>
<Col>
{this.state.showLog ? <TaskLogs taskID={this.props.match.params.tid} /> : <Button click={() => this.setState({showLog:true})}>Show Log</Button>}
{this.state.showLog ? <TaskLogs taskID={this.props.match.params.tid} /> : <Button onClick={() => this.setState({showLog:true})}>Show Log</Button>}
</Col>
</Form.Row>
</Form>

View File

@@ -0,0 +1,124 @@
package server
import (
"archive/zip"
"context"
"encoding/json"
"net/http"
"os"
"github.com/pkg/errors"
"github.com/kopia/kopia/internal/ctxutil"
"github.com/kopia/kopia/internal/serverapi"
"github.com/kopia/kopia/internal/uitask"
"github.com/kopia/kopia/snapshot/restore"
"github.com/kopia/kopia/snapshot/snapshotfs"
)
func restoreCounters(s restore.Stats) map[string]uitask.CounterValue {
return map[string]uitask.CounterValue{
"Restored Files": uitask.SimpleCounter(int64(s.RestoredFileCount)),
"Restored Directories": uitask.SimpleCounter(int64(s.RestoredDirCount)),
"Restored Symlinks": uitask.SimpleCounter(int64(s.RestoredSymlinkCount)),
"Restored Bytes": uitask.BytesCounter(s.RestoredTotalFileSize),
"Ignored Errors": uitask.SimpleCounter(int64(s.IgnoredErrorCount)),
"Skipped Files": uitask.SimpleCounter(int64(s.SkippedCount)),
"Skipped Bytes": uitask.BytesCounter(s.SkippedTotalFileSize),
}
}
func (s *Server) handleRestore(ctx context.Context, r *http.Request, body []byte) (interface{}, *apiError) {
var req serverapi.RestoreRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, requestError(serverapi.ErrorMalformedRequest, "malformed request body")
}
ctx = ctxutil.Detach(ctx)
rep := s.rep
if req.Root == "" {
return nil, requestError(serverapi.ErrorMalformedRequest, "root not specified")
}
rootEntry, err := snapshotfs.FilesystemEntryFromIDWithPath(ctx, rep, req.Root, false)
if err != nil {
return nil, internalServerError(err)
}
var (
out restore.Output
description string
)
switch {
case req.Filesystem != nil:
out = req.Filesystem
description = "Destination: " + req.Filesystem.TargetPath
case req.ZipFile != "":
f, err := os.Create(req.ZipFile)
if err != nil {
return nil, internalServerError(err)
}
if req.UncompressedZip {
out = restore.NewZipOutput(f, zip.Store)
description = "Uncompressed ZIP File: " + req.ZipFile
} else {
out = restore.NewZipOutput(f, zip.Deflate)
description = "ZIP File: " + req.ZipFile
}
case req.TarFile != "":
f, err := os.Create(req.TarFile)
if err != nil {
return nil, internalServerError(err)
}
out = restore.NewTarOutput(f)
description = "TAR File: " + req.TarFile
default:
return nil, requestError(serverapi.ErrorMalformedRequest, "output not specified")
}
taskIDChan := make(chan string)
// launch a goroutine that will continue the restore and can be observed in the Tasks UI.
// nolint:errcheck
go s.taskmgr.Run(ctx, "Restore", description, func(ctx context.Context, ctrl uitask.Controller) error {
taskIDChan <- ctrl.CurrentTaskID()
opt := req.Options
opt.ProgressCallback = func(ctx context.Context, s restore.Stats) {
ctrl.ReportCounters(restoreCounters(s))
}
cancelChan := make(chan struct{})
opt.Cancel = cancelChan
ctrl.OnCancel(func() {
close(opt.Cancel)
})
st, err := restore.Entry(ctx, rep, out, rootEntry, opt)
if err == nil {
ctrl.ReportCounters(restoreCounters(st))
}
return errors.Wrap(err, "error restoring")
})
taskID := <-taskIDChan
task, ok := s.taskmgr.GetTask(taskID)
if !ok {
return nil, internalServerError(errors.Errorf("task not found"))
}
return task, nil
}

View File

@@ -77,6 +77,7 @@ func (s *Server) APIHandlers(legacyAPI bool) http.Handler {
m.HandleFunc("/api/v1/shutdown", s.handleAPIPossiblyNotConnected(s.handleShutdown)).Methods(http.MethodPost)
m.HandleFunc("/api/v1/objects/{objectID}", s.requireAuth(s.handleObjectGet)).Methods(http.MethodGet)
m.HandleFunc("/api/v1/restore", s.handleAPI(s.handleRestore)).Methods(http.MethodPost)
m.HandleFunc("/api/v1/repo/status", s.handleAPIPossiblyNotConnected(s.handleRepoStatus)).Methods(http.MethodGet)
m.HandleFunc("/api/v1/repo/connect", s.handleAPIPossiblyNotConnected(s.handleRepoConnect)).Methods(http.MethodPost)

View File

@@ -12,6 +12,7 @@
"github.com/kopia/kopia/repo/object"
"github.com/kopia/kopia/snapshot"
"github.com/kopia/kopia/snapshot/policy"
"github.com/kopia/kopia/snapshot/restore"
"github.com/kopia/kopia/snapshot/snapshotfs"
)
@@ -200,3 +201,16 @@ type TaskListResponse struct {
type TaskLogResponse struct {
Logs []uitask.LogEntry `json:"logs"`
}
// RestoreRequest contains request to restore an object (file or directory) to a given destination.
type RestoreRequest struct {
Root string `json:"root"`
Filesystem *restore.FilesystemOutput `json:"fsOutput"`
ZipFile string `json:"zipFile"`
UncompressedZip bool `json:"uncompressedZip"`
TarFile string `json:"tarFile"`
Options restore.Options `json:"options"`
}

View File

@@ -23,32 +23,32 @@
// FilesystemOutput contains the options for outputting a file system tree.
type FilesystemOutput struct {
// TargetPath for restore.
TargetPath string
TargetPath string `json:"targetPath"`
// If a directory already exists, overwrite the directory.
OverwriteDirectories bool
OverwriteDirectories bool `json:"overwriteDirectories"`
// Indicate whether or not to overwrite existing files. When set to false,
// the copier does not modify already existing files and returns an error
// instead.
OverwriteFiles bool
OverwriteFiles bool `json:"overwriteFiles"`
// If a symlink already exists, remove it and create a new one. When set to
// false, the copier does not modify existing symlinks and will return an
// error instead.
OverwriteSymlinks bool
OverwriteSymlinks bool `json:"overwriteSymlinks"`
// IgnorePermissionErrors causes restore to ignore errors due to invalid permissions.
IgnorePermissionErrors bool
IgnorePermissionErrors bool `json:"ignorePermissionErrors"`
// SkipOwners when set to true causes restore to skip restoring owner information.
SkipOwners bool
SkipOwners bool `json:"skipOwners"`
// SkipPermissions when set to true causes restore to skip restoring permission information.
SkipPermissions bool
SkipPermissions bool `json:"skipPermissions"`
// SkipTimes when set to true causes restore to skip restoring modification times.
SkipTimes bool
SkipTimes bool `json:"skipTimes"`
}
// Parallelizable implements restore.Output interface.

View File

@@ -63,10 +63,12 @@ func (s *Stats) clone() Stats {
// Options provides optional restore parameters.
type Options struct {
Parallel int
Parallel int `json:"parallel"`
Incremental bool `json:"incremental"`
IgnoreErrors bool `json:"ignoreErrors"`
ProgressCallback func(ctx context.Context, s Stats)
Incremental bool
IgnoreErrors bool
Cancel chan struct{} // channel that can be externally closed to signal cancelation
}
// Entry walks a snapshot root with given root entry and restores it to the provided output.
@@ -76,10 +78,13 @@ func Entry(ctx context.Context, rep repo.Repository, output Output, rootEntry fs
q: parallelwork.NewQueue(),
incremental: options.Incremental,
ignoreErrors: options.IgnoreErrors,
cancel: options.Cancel,
}
c.q.ProgressCallback = func(ctx context.Context, enqueued, active, completed int64) {
options.ProgressCallback(ctx, c.stats.clone())
if options.ProgressCallback != nil {
options.ProgressCallback(ctx, c.stats.clone())
}
}
c.q.EnqueueFront(ctx, func() error {
@@ -112,9 +117,19 @@ type copier struct {
q *parallelwork.Queue
incremental bool
ignoreErrors bool
cancel chan struct{}
}
func (c *copier) copyEntry(ctx context.Context, e fs.Entry, targetPath string, onCompletion func() error) error {
if c.cancel != nil {
select {
case <-c.cancel:
return onCompletion()
default:
}
}
if c.incremental {
// in incremental mode, do not copy if the output already exists
switch e := e.(type) {