From 504238df7a31189174cc263fd46b20780a2b301e Mon Sep 17 00:00:00 2001 From: Jarek Kowalski Date: Thu, 11 Feb 2021 02:08:47 -0800 Subject: [PATCH] Restore UI (#823) * server: added restore api * htmlui: restore UI --- htmlui/src/App.css | 16 ++++ htmlui/src/App.js | 2 + htmlui/src/BeginRestore.js | 133 ++++++++++++++++++++++++++++ htmlui/src/DirectoryObject.js | 2 + htmlui/src/TaskDetails.js | 4 +- internal/server/api_restore.go | 124 ++++++++++++++++++++++++++ internal/server/server.go | 1 + internal/serverapi/serverapi.go | 14 +++ snapshot/restore/local_fs_output.go | 16 ++-- snapshot/restore/restore.go | 23 ++++- 10 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 htmlui/src/BeginRestore.js create mode 100644 internal/server/api_restore.go diff --git a/htmlui/src/App.css b/htmlui/src/App.css index 21067c98b..fba568f45 100644 --- a/htmlui/src/App.css +++ b/htmlui/src/App.css @@ -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; +} \ No newline at end of file diff --git a/htmlui/src/App.js b/htmlui/src/App.js index 0088e2f12..61a36b1a2 100644 --- a/htmlui/src/App.js +++ b/htmlui/src/App.js @@ -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() { + diff --git a/htmlui/src/BeginRestore.js b/htmlui/src/BeginRestore.js new file mode 100644 index 000000000..98a4ca74d --- /dev/null +++ b/htmlui/src/BeginRestore.js @@ -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

+ + Go To Restore Task. +

; + } + + return
+  Restore +
+
+ + {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.")} + + + {RequiredBoolean(this, "Skip previously restored files and symlinks", "incremental")} + + + {RequiredBoolean(this, "Continue on Errors", "continueOnErrors", "When a restore error occurs, attempt to continue instead of failing fast.")} + + + {RequiredBoolean(this, "Restore File Ownership", "restoreOwnership")} + + + {RequiredBoolean(this, "Restore File Permissions", "restorePermissions")} + + + {RequiredBoolean(this, "Restore File Modification Time", "restoreModTimes")} + + + {RequiredBoolean(this, "Overwrite Files", "overwriteFiles")} + + + {RequiredBoolean(this, "Overwrite Directories", "overwriteDirectories")} + + + {RequiredBoolean(this, "Overwrite Symbolic Links", "overwriteSymlinks")} + + + {RequiredBoolean(this, "Disable ZIP compression", "uncompressedZip", "Do not compress when restoring to a ZIP file (faster).")} + + + + +
+
; + } +} diff --git a/htmlui/src/DirectoryObject.js b/htmlui/src/DirectoryObject.js index 9b66b3319..e7b8bc899 100644 --- a/htmlui/src/DirectoryObject.js +++ b/htmlui/src/DirectoryObject.js @@ -131,6 +131,8 @@ export class DirectoryObject extends Component { : <> } +   +
diff --git a/htmlui/src/TaskDetails.js b/htmlui/src/TaskDetails.js index 6a35bebee..378ec69dc 100644 --- a/htmlui/src/TaskDetails.js +++ b/htmlui/src/TaskDetails.js @@ -117,7 +117,7 @@ export class TaskDetails extends Component { break; } - return {label}: {formatted} + return {label}: {formatted} } render() { @@ -169,7 +169,7 @@ export class TaskDetails extends Component {
- {this.state.showLog ? : } + {this.state.showLog ? : } diff --git a/internal/server/api_restore.go b/internal/server/api_restore.go new file mode 100644 index 000000000..aceca9c28 --- /dev/null +++ b/internal/server/api_restore.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index d10a969a3..c76cbb9db 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/serverapi/serverapi.go b/internal/serverapi/serverapi.go index fd5be3ff2..7222dcfff 100644 --- a/internal/serverapi/serverapi.go +++ b/internal/serverapi/serverapi.go @@ -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"` +} diff --git a/snapshot/restore/local_fs_output.go b/snapshot/restore/local_fs_output.go index cb32b1de9..04e3f9890 100644 --- a/snapshot/restore/local_fs_output.go +++ b/snapshot/restore/local_fs_output.go @@ -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. diff --git a/snapshot/restore/restore.go b/snapshot/restore/restore.go index cb61e270d..d8f91bafa 100644 --- a/snapshot/restore/restore.go +++ b/snapshot/restore/restore.go @@ -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) {