mirror of
https://github.com/kopia/kopia.git
synced 2026-04-04 06:22:59 -04:00
@@ -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;
|
||||
}
|
||||
@@ -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
133
htmlui/src/BeginRestore.js
Normal 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} /> <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>;
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,8 @@ export class DirectoryObject extends Component {
|
||||
</> : <>
|
||||
<Button size="sm" variant="primary" onClick={this.mount} >Mount</Button>
|
||||
</>}
|
||||
|
||||
<Button size="sm" variant="info" href={"/snapshots/dir/" + this.props.match.params.oid +"/restore"}>Restore...</Button>
|
||||
</Row>
|
||||
<hr/>
|
||||
<Row>
|
||||
|
||||
@@ -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>
|
||||
|
||||
124
internal/server/api_restore.go
Normal file
124
internal/server/api_restore.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user