mirror of https://github.com/micromata/borgbackup-butler.git

Fin Reinhard
22.51.2019 1c087fae322a1b07bb7bd554ee10ff473c47c727
Merge branch 'master' into feature/15-archive-view-url
10 files added
38 files modified
1937 ■■■■ changed files
borgbutler-core/build.gradle 4 ●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/BorgCommands.java 50 ●●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/BorgJob.java 12 ●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/BorgQueueExecutor.java 5 ●●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/cache/ButlerCache.java 10 ●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/config/BorgRepoConfig.java 9 ●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/config/Configuration.java 17 ●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/demo/DemoRepos.java 32 ●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/jobs/AbstractCommandLineJob.java 6 ●●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/jobs/AbstractJob.java 32 ●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/jobs/JobQueue.java 8 ●●●● patch | view | raw | blame | history
borgbutler-core/src/test/java/de/micromata/borgbutler/cache/ArchiveFilelistCacheTest.java 2 ●●● patch | view | raw | blame | history
borgbutler-server/build.gradle 2 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/BorgInstallation.java 196 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/BorgVersion.java 54 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/Main.java 8 ●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/RunningMode.java 4 ●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/ServerConfiguration.java 14 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ArchivesRest.java 18 ●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/BorgRepoConfigsRest.java 44 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ConfigurationRest.java 16 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/FilesystemBrowserRest.java 84 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/I18nRest.java 6 ●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/JobsRest.java 74 ●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ReposRest.java 23 ●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/SystemInfo.java 25 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/SystemInfoRest.java 34 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/VersionRest.java 6 ●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/queue/JsonJob.java 12 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/resources/log4j.properties 2 ●●● patch | view | raw | blame | history
borgbutler-server/src/test/java/de/micromata/borgbutler/server/BorgInstallationTest.java 38 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/general/forms/FormComponents.jsx 20 ●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/general/forms/FormRadioButton.jsx 62 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/general/forms/FormSelect.jsx 9 ●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/archives/ArchiveView.jsx 4 ●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/config/ConfigurationPage.jsx 24 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/config/ConfigurationServerTab.jsx 97 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/jobs/Job.css 8 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/jobs/Job.jsx 36 ●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/jobs/JobMonitorPanel.jsx 102 ●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/jobs/JobQueue.jsx 58 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/repos/ConfigureRepoPage.jsx 252 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/repos/RepoArchiveListView.jsx 202 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/repos/RepoCard.jsx 2 ●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/repos/RepoConfigPanel.jsx 153 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/repos/RepoListView.jsx 21 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/containers/WebApp.jsx 23 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/css/my-style.css 17 ●●●●● patch | view | raw | blame | history
borgbutler-core/build.gradle
@@ -16,8 +16,8 @@
    compile group: 'com.esotericsoftware', name: 'kryo', version: '5.0.0-RC1'
    // Serialization (faster than Java built-in)
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.9.6'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: '2.9.6'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.9.8'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: '2.9.8'
    compileOnly "org.projectlombok:lombok:1.18.4"
    testCompileOnly "org.projectlombok:lombok:1.18.4"
borgbutler-core/src/main/java/de/micromata/borgbutler/BorgCommands.java
@@ -10,6 +10,7 @@
import de.micromata.borgbutler.utils.DateUtils;
import de.micromata.borgbutler.utils.ReplaceUtils;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -35,16 +36,56 @@
        BorgCommand command = new BorgCommand()
                .setParams("--version")
                .setDescription("Getting borg version.");
        JobResult<String> jobResult = getResult(command);
        BorgJob<String> job = new BorgJob<>(command);
        JobResult<String> jobResult = job.execute();
        if (jobResult == null || jobResult.getStatus() != JobResult.Status.OK) {
            return null;
        }
        String version = jobResult.getResultObject();
        String origVersion = jobResult.getResultObject();
        String version = origVersion;
        String[] strs = StringUtils.split(origVersion);
        if (strs != null) {
            if (!StringUtils.containsIgnoreCase(origVersion, "borg")) {
                version = "";
            } else {
                version = strs[strs.length - 1]; // Work arround: borg returns "borg-macosx64 1.1.8" as version string (command is included).
            }
        }
        if (version.length() == 0 || !Character.isDigit(version.charAt(0))) {
            log.error("Version string returned by '" + job.getCommandLineAsString() + "' not as expected (not borg?): " + origVersion);
            return null;
        }
        log.info("Borg version: " + version);
        return version;
    }
    /**
     * Executes borg init repository.
     *
     * @param repoConfig The configuration of the repo config (only repo is required).
     * @param encryption The encryption value (repokey, repokey-blake2, none, ...).
     * @return true, if no errors occured, otherwise false.
     */
    public static boolean init(BorgRepoConfig repoConfig, String encryption) {
        BorgCommand command = new BorgCommand()
                .setRepoConfig(repoConfig)
                .setCommand("init")
                //.setParams("--json") // --progress has no effect.
                .setDescription("Init new repo '" + repoConfig.getDisplayName() + "'.");
        JobResult<String> jobResult = getResult(command);
        String result = jobResult != null ? jobResult.getResultObject() : null;
        // If everything is ok, now String will be returned, result must be blank:
        if (jobResult == null || jobResult.getStatus() != JobResult.Status.OK || StringUtils.isNotBlank(result)) {
            log.error("Error while trying to intialize repo '" + repoConfig.getRepo() + "': " + result);
            return false;
        }
        log.error("Error while trying to intialize repo '" + repoConfig.getRepo() + "': " + result);
        return false;
    }
    /**
     * Executes borg info repository.
     *
     * @param repoConfig
@@ -176,8 +217,9 @@
        // The returned job might be an already queued or running one!
        final ProgressInfo progressInfo = new ProgressInfo()
                .setMessage("Getting file list...")
                .setCurrent(0)
                .setTotal(archive.getStats().getNfiles());
                .setCurrent(0);
        if (archive.getStats() != null) // Occurs only for demo repos.
            progressInfo.setTotal(archive.getStats().getNfiles());
        BorgJob<List<BorgFilesystemItem>> job = BorgQueueExecutor.getInstance().execute(new BorgJob<List<BorgFilesystemItem>>(command) {
            @Override
            public void processStdOutLine(String line, int level) {
borgbutler-core/src/main/java/de/micromata/borgbutler/BorgJob.java
@@ -52,7 +52,12 @@
        if (command == null) {
            return null;
        }
        CommandLine commandLine = new CommandLine(ConfigurationHandler.getConfiguration().getBorgCommand());
        String borgCommand = ConfigurationHandler.getConfiguration().getBorgCommand();
        if (StringUtils.isBlank(borgCommand)) {
            log.error("Can't run empty borg command.");
            return null;
        }
        CommandLine commandLine = new CommandLine(borgCommand);
        commandLine.addArgument(command.getCommand());
        if (command.getParams() != null) {
            for (String param : command.getParams()) {
@@ -101,7 +106,7 @@
    @Override
    public JobResult<String> execute() {
        if (DemoRepos.isDemo(command.getRepoConfig().getRepo())) {
        if (command.getRepoConfig() != null && DemoRepos.isDemo(command.getRepoConfig().getRepo())) {
            return DemoRepos.execute(this);
        }
        return super.execute();
@@ -125,6 +130,9 @@
        if (progressInfo != null) {
            clone.setProgressInfo(progressInfo.clone());
        }
        clone.setCreateTime(getCreateTime());
        clone.setStartTime(getStartTime());
        clone.setStopTime(getStopTime());
        return clone;
    }
borgbutler-core/src/main/java/de/micromata/borgbutler/BorgQueueExecutor.java
@@ -78,16 +78,17 @@
     * For displaying purposes.
     *
     * @param repoConfig
     * @param oldJobs If false, the running and queued jobs are returned, otherwise the done ones.
     * @return A list of all jobs of the queue (as copies).
     */
    public List<BorgJob<?>> getJobListCopy(BorgRepoConfig repoConfig) {
    public List<BorgJob<?>> getJobListCopy(BorgRepoConfig repoConfig, boolean oldJobs) {
        JobQueue<String> origQueue = getQueue(repoConfig);
        List<BorgJob<?>> jobList = new ArrayList<>();
        if (origQueue == null) {
            return jobList;
        }
        synchronized (origQueue) {
            Iterator<AbstractJob<String>> it = origQueue.getQueueIterator();
            Iterator<AbstractJob<String>> it = oldJobs ? origQueue.getOldJobsIterator() : origQueue.getQueueIterator();
            while (it.hasNext()) {
                AbstractJob<String> origJob = it.next();
                if (!(origJob instanceof BorgJob)) {
borgbutler-core/src/main/java/de/micromata/borgbutler/cache/ButlerCache.java
@@ -124,9 +124,15 @@
        this.repoCacheAccess.clear();
    }
    public void clearRepoCacheAccess(String repo) {
        if (this.repoCacheAccess.get(repo) != null) {
            log.info("Clearing repository cache '" + repo + "'...");
            this.repoCacheAccess.remove(repo);
        }
    }
    public void clearRepoCacheAccess(Repository repository) {
        log.info("Clearing repository cache '" + repository.getName() + "'...");
        this.repoCacheAccess.remove(repository.getName());
        clearRepoCacheAccess(repository.getName());
    }
    /**
borgbutler-core/src/main/java/de/micromata/borgbutler/config/BorgRepoConfig.java
@@ -1,6 +1,5 @@
package de.micromata.borgbutler.config;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
@@ -29,7 +28,6 @@
    private String passwordCommand;
    @Getter
    @Setter
    @JsonIgnore
    private String id;
    public String[] getEnvironmentVariables() {
@@ -51,4 +49,11 @@
        if (StringUtils.isBlank(value)) return;
        list.add(variable + "=" + value);
    }
   public void copyFrom(BorgRepoConfig other) {
        this.displayName = other.displayName;
        this.repo = other.repo;
        this.passphrase = other.passphrase;
        this.passwordCommand = other.passwordCommand;
   }
}
borgbutler-core/src/main/java/de/micromata/borgbutler/config/Configuration.java
@@ -24,9 +24,13 @@
    @JsonIgnore
    @Setter(AccessLevel.PACKAGE)
    private File workingDir;
    /**
     * The path of the borg command to use.
     */
    @Getter
    private String borgCommand = "borg";
    @Setter
    private String borgCommand;
    /**
     * Default is 100 MB (approximately).
     */
@@ -85,13 +89,8 @@
    }
    public List<BorgRepoConfig> getRepoConfigs() {
        if (!ConfigurationHandler.getConfiguration().isShowDemoRepos()) {
            return repoConfigs;
        }
        List<BorgRepoConfig> result = new ArrayList<>();
        result.addAll(repoConfigs);
        DemoRepos.addDemoRepos(result);
        return result;
        DemoRepos.handleDemoRepos(repoConfigs);
        return repoConfigs;
    }
    List<BorgRepoConfig> _getRepoConfigs() {
borgbutler-core/src/main/java/de/micromata/borgbutler/demo/DemoRepos.java
@@ -18,9 +18,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
import java.util.*;
public class DemoRepos {
    private enum Type {FAST, SLOW, VERY_SLOW}
@@ -36,13 +34,37 @@
     *
     * @param repositoryList
     */
    public static void addDemoRepos(List<BorgRepoConfig> repositoryList) {
    public static void handleDemoRepos(List<BorgRepoConfig> repositoryList) {
        if (!ConfigurationHandler.getConfiguration().isShowDemoRepos()) {
            // Remove any demo repository if exist due to former settings:
            Iterator<BorgRepoConfig> it = repositoryList.iterator();
            while(it.hasNext()) {
                BorgRepoConfig repoConfig = it.next();
                if (!StringUtils.startsWith(repoConfig.getRepo(), DEMO_IDENTIFIER)) {
                    continue;
                }
                it.remove();
            }
            return;
        }
        init();
        for (BorgRepoConfig repo : demoRepos) {
            repositoryList.add(repo);
            if (!repositoryList.contains(repo))
                repositoryList.add(repo);
        }
        // Remove duplicate entries (produced by former versions of BorgButler:
        Set<String> set = new HashSet<>();
        Iterator<BorgRepoConfig> it = repositoryList.iterator();
        while(it.hasNext()) {
            BorgRepoConfig repoConfig = it.next();
            if (!StringUtils.startsWith(repoConfig.getRepo(), DEMO_IDENTIFIER)) {
                continue;
            }
            if (set.contains(repoConfig.getRepo())) {
                it.remove();
            } else {
                set.add(repoConfig.getRepo());
            }
        }
    }
borgbutler-core/src/main/java/de/micromata/borgbutler/jobs/AbstractCommandLineJob.java
@@ -52,6 +52,9 @@
        if (commandLine == null) {
            commandLine = buildCommandLine();
        }
        if (commandLine == null) {
            return null;
        }
        if (commandLineAsString == null) {
            commandLineAsString = commandLine.getExecutable() + " " + StringUtils.join(commandLine.getArguments(), " ");
        }
@@ -61,6 +64,9 @@
    @Override
    public JobResult<String> execute() {
        getCommandLineAsString();
        if (commandLine == null) {
            return null;
        }
        DefaultExecutor executor = new DefaultExecutor();
        if (workingDirectory != null) {
            executor.setWorkingDirectory(workingDirectory);
borgbutler-core/src/main/java/de/micromata/borgbutler/jobs/AbstractJob.java
@@ -1,11 +1,13 @@
package de.micromata.borgbutler.jobs;
import de.micromata.borgbutler.utils.DateUtils;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDateTime;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
@@ -18,7 +20,6 @@
    @Setter
    private boolean cancellationRequested;
    @Getter
    @Setter(AccessLevel.PROTECTED)
    private Status status;
    @Getter
    @Setter
@@ -29,17 +30,36 @@
    @Getter
    @Setter(AccessLevel.PROTECTED)
    private long uniqueJobNumber = -1;
    @Getter
    @Setter
    private String createTime;
    @Getter
    @Setter
    private String startTime;
    @Getter
    @Setter
    private String stopTime;
    protected AbstractJob<T> setStatus(Status status) {
        if (status == Status.RUNNING && this.status != Status.RUNNING) {
            this.startTime = DateUtils.format(LocalDateTime.now());
        } else if (status != Status.RUNNING && this.status == Status.RUNNING) {
            this.stopTime = DateUtils.format(LocalDateTime.now());
        }
        this.status = status;
        return this;
    }
    public void cancel() {
        if (this.getStatus() == Status.QUEUED) {
            this.status = Status.CANCELLED;
            setStatus(Status.CANCELLED);
        }
        this.cancellationRequested = true;
        cancelRunningProcess();
    }
    protected void setCancelled() {
        this.status = Status.CANCELLED;
        setStatus(Status.CANCELLED);
    }
    /**
@@ -74,7 +94,7 @@
        if (this.status != Status.RUNNING) {
            logger.error("Internal error, illegal state! You shouldn't set the job status to FAILED if not in status RUNNING: " + this.status);
        }
        this.status = Status.FAILED;
        setStatus(Status.FAILED);
    }
    /**
@@ -96,4 +116,8 @@
     * @return
     */
    public abstract Object getId();
    protected AbstractJob() {
        this.createTime = DateUtils.format(LocalDateTime.now());
    }
}
borgbutler-core/src/main/java/de/micromata/borgbutler/jobs/JobQueue.java
@@ -9,7 +9,7 @@
import java.util.concurrent.Executors;
public class JobQueue<T> {
    private static final int MAX_OLD_JOBS_SIZE = 50;
    private static final int MAX_OLD_JOBS_SIZE = 10;
    private static long jobSequence = 0;
    private Logger log = LoggerFactory.getLogger(JobQueue.class);
    private List<AbstractJob<T>> queue = new ArrayList<>();
@@ -43,6 +43,12 @@
        }
    }
    public Iterator<AbstractJob<T>> getOldJobsIterator() {
        synchronized (oldJobs) {
            return Collections.unmodifiableList(oldJobs).iterator();
        }
    }
    /**
     * Searches only for queued jobs (not done jobs).
     *
borgbutler-core/src/test/java/de/micromata/borgbutler/cache/ArchiveFilelistCacheTest.java
@@ -63,7 +63,7 @@
    @Test
    void cleanUpMaximumSizeTest() throws Exception {
        List<BorgFilesystemItem> list = createList(1000000);
        ArchiveFilelistCache cache = new ArchiveFilelistCache(new File("out"), 3);
        ArchiveFilelistCache cache = new ArchiveFilelistCache(new File("out"), 5);
        cache.removeAllCacheFiles();
        BorgRepoConfig repoConfig = new BorgRepoConfig();
        repoConfig.setRepo("repo");
borgbutler-server/build.gradle
@@ -24,8 +24,6 @@
    compile group: 'org.glassfish.jersey.media', name: 'jersey-media-multipart', version: '2.27'
    compile group: 'org.glassfish.jersey.media', name: 'jersey-media-json-jackson', version: '2.27'
    compile group: 'org.glassfish.jersey.inject', name: 'jersey-hk2', version: '2.27'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.9.6'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-annotations', version: '2.9.6'
    compile group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.1'
    compile group: 'javax.xml.ws', name: 'jaxws-api', version: '2.3.1'
    compile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.25'
borgbutler-server/src/main/java/de/micromata/borgbutler/server/BorgInstallation.java
New file
@@ -0,0 +1,196 @@
package de.micromata.borgbutler.server;
import de.micromata.borgbutler.BorgCommands;
import de.micromata.borgbutler.config.Configuration;
import de.micromata.borgbutler.config.ConfigurationHandler;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpResponse;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
public class BorgInstallation {
    private Logger log = LoggerFactory.getLogger(BorgInstallation.class);
    private static final BorgInstallation instance = new BorgInstallation();
    public static BorgInstallation getInstance() {
        return instance;
    }
    public void initialize() {
        Configuration configuration = ConfigurationHandler.getConfiguration();
        if (StringUtils.isNotBlank(configuration.getBorgCommand())) {
            if (version(configuration)) {
                return;
            }
        }
        initialize(getBinary(RunningMode.getOSType()));
        if (version(configuration)) {
            return;
        }
        BorgVersion borgVersion = ServerConfiguration.get()._getBorgVersion();
        log.warn("No working borg version found. Please configure a borg version with minimal version '" + borgVersion.getMinimumRequiredBorgVersion() + "'.");
    }
    /**
     * Configures a new borg configuration if modifications was done.
     * @param oldVersion The old version before this current modification.
     */
    public void configure(BorgVersion oldVersion) {
        Configuration configuration = ConfigurationHandler.getConfiguration();
        BorgVersion newVersion = ServerConfiguration.get()._getBorgVersion();
        boolean borgBinaryChanged = !StringUtils.equals(oldVersion.getBorgBinary(), newVersion.getBorgBinary());
        boolean manualBorgCommand = "manual".equals(newVersion.getBorgBinary());
        if (manualBorgCommand) {
            boolean borgCommandChanged = !StringUtils.equals(oldVersion.getBorgCommand(), newVersion.getBorgCommand());
            if (borgCommandChanged) {
                configuration.setBorgCommand(newVersion.getBorgCommand());
                version(configuration);
            } else {
                newVersion.copyFrom(oldVersion); // Restore all old settings.
            }
        } else {
            if (borgBinaryChanged) {
                newVersion.setBorgCommand(oldVersion.getBorgCommand()); // Don't modify borg command, if not manual.
                initialize(getBinary(newVersion.getBorgBinary()));
            } else {
                newVersion.copyFrom(oldVersion); // Restore all old settings.
            }
        }
    }
    private boolean initialize(String[] binary) {
        if (binary == null) {
            return false;
        }
        Configuration configuration = ConfigurationHandler.getConfiguration();
        File file = download(binary);
        BorgVersion borgVersion = ServerConfiguration.get()._getBorgVersion();
        if (file != null) {
            configuration.setBorgCommand(file.getAbsolutePath());
            borgVersion.setBorgCommand(file.getAbsolutePath());
            borgVersion.setBorgBinary(binary[0]);
        }
        return version(configuration);
    }
    private boolean version(Configuration configuration) {
        String versionString = BorgCommands.version();
        boolean versionOK = false;
        BorgVersion borgVersion = ServerConfiguration.get()._getBorgVersion();
        String msg = null;
        if (versionString != null) {
            borgVersion.setVersion(versionString);
            int cmp = versionString.compareTo(borgVersion.getMinimumRequiredBorgVersion());
            if (cmp < 0) {
                msg = "Found borg version '" + versionString + "' is less than minimum required version '" + borgVersion.getMinimumRequiredBorgVersion() + "'.";
                log.info(msg);
            } else {
                versionOK = true;
                msg = "Found borg '" + configuration.getBorgCommand() + "', version: " + versionString + " (equals to or newer than '" + borgVersion.getMinimumRequiredBorgVersion()
                        + "', OK).";
                log.info(msg);
            }
        } else {
            msg = "Couldn't execute borg command '" + configuration.getBorgCommand() + "'.";
        }
        borgVersion.setVersionOK(versionOK);
        borgVersion.setStatusMessage(msg);
        return versionOK;
    }
    private String[] getBinary(RunningMode.OSType osType) {
        String os = null;
        switch (osType) {
            case MAC_OS:
                os = "mac";
                break;
            case LINUX:
                os = "linux64";
                break;
            case FREEBSD:
                os = "freebsd64";
                break;
        }
        return getBinary(os);
    }
    private String[] getBinary(String os) {
        if (os == null) {
            return null;
        }
        BorgVersion borgVersion = ServerConfiguration.get()._getBorgVersion();
        for (String[] binary : borgVersion.getBorgBinaries()) {
            if (binary[0].contains(os)) {
                return binary;
            }
        }
        return null;
    }
    File download(RunningMode.OSType osType) {
        String[] binary = getBinary(osType);
        if (binary == null) {
            log.info("Can't download binary (no binary found for OS '" + osType + "'.");
            return null;
        }
        return download(binary);
    }
    private File download(String[] binary) {
        File file = getBinaryFile(binary);
        if (file.exists()) {
            // File already downloaded, nothing to do.
            return file;
        }
        BorgVersion borgVersion = ServerConfiguration.get()._getBorgVersion();
        String url = borgVersion.getBinariesDownloadUrl() + getDownloadFilename(binary);
        log.info("Trying to download borg binary '" + binary[0] + "' (" + binary[1] + ") from url: " + url + "...");
        HttpClientBuilder builder = HttpClients.custom()
                .setDefaultRequestConfig(RequestConfig.custom()
                        .setCookieSpec(CookieSpecs.STANDARD).build());
        try (CloseableHttpClient httpClient = builder.build()) {
            HttpGet getRequest = new HttpGet(url);
            HttpResponse response = httpClient.execute(getRequest);
            if (response.getStatusLine().getStatusCode() != 200) {
                throw new RuntimeException("Failed : HTTP error code : "
                        + response.getStatusLine().getStatusCode());
            }
            FileUtils.copyInputStreamToFile(response.getEntity().getContent(), file);
            log.info("Downloaded: " + file.getAbsolutePath());
            file.setExecutable(true, false);
            return file;
        } catch (IOException ex) {
            log.error(ex.getMessage(), ex);
            return null;
        }
    }
    private File getBinaryFile(String[] binary) {
        File dir = new File(ConfigurationHandler.getInstance().getWorkingDir(), "bin");
        if (!dir.exists()) {
            log.info("Creating binary directory: " + dir.getAbsolutePath());
            dir.mkdirs();
        }
        BorgVersion borgVersion = ServerConfiguration.get()._getBorgVersion();
        return new File(dir, getDownloadFilename(binary) + "-" + borgVersion.getBinariesDownloadVersion());
    }
    private String getDownloadFilename(String[] binary) {
        return "borg-" + binary[0];
    }
    private BorgInstallation() {
    }
}
borgbutler-server/src/main/java/de/micromata/borgbutler/server/BorgVersion.java
New file
@@ -0,0 +1,54 @@
package de.micromata.borgbutler.server;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
public class BorgVersion {
    @Getter
    private String binariesDownloadVersion = "1.1.8";
    @Getter
    private String binariesDownloadUrl = "https://github.com/borgbackup/borg/releases/download/" + binariesDownloadVersion + "/";
    @Getter
    private String[][] borgBinaries = {
            {"freebsd64", "FreeBSD 64"},
            {"linux32", "Linux 32"},
            {"linux64", "Linux 64"},
            {"macosx64", "MacOS X 64"}};
    @Getter
    private String minimumRequiredBorgVersion = "1.1.8";
    /**
     * One of the values "macosx64", "linux64" etc. for using a binary provided by BorgButler or null / "manual" for
     * using a manual installed borg version.
     */
    @Getter
    @Setter(AccessLevel.PACKAGE)
    private String borgBinary;
    /**
     * The path of the borg command to use.
     */
    @Getter
    @Setter
    private String borgCommand;
    @Getter
    @Setter(AccessLevel.PACKAGE)
    private boolean versionOK = false;
    @Getter
    @Setter(AccessLevel.PACKAGE)
    private String version;
    @Getter
    @Setter(AccessLevel.PACKAGE)
    private String statusMessage;
    public BorgVersion copyFrom(BorgVersion other) {
        this.borgCommand = other.borgCommand;
        this.borgBinary = other.borgBinary;
        this.versionOK = other.versionOK;
        this.version = other.version;
        this.statusMessage = other.statusMessage;
        return this;
    }
}
borgbutler-server/src/main/java/de/micromata/borgbutler/server/Main.java
@@ -14,6 +14,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.io.*;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
@@ -81,7 +82,11 @@
                    return;
                }
            }
            RunningMode.setServerType(RunningMode.ServerType.SERVER);
            if (Desktop.isDesktopSupported()) {
                RunningMode.setServerType(RunningMode.ServerType.DESKTOP);
            } else {
                RunningMode.setServerType(RunningMode.ServerType.SERVER);
            }
            RunningMode.logMode();
            Runtime.getRuntime().addShutdownHook(new Thread() {
                @Override
@@ -91,6 +96,7 @@
            });
            JettyServer server = startUp();
            BorgInstallation.getInstance().initialize();
            if (!line.hasOption('q')) {
                try {
borgbutler-server/src/main/java/de/micromata/borgbutler/server/RunningMode.java
@@ -16,7 +16,7 @@
    public enum UserManagement {SINGLE}
    public enum OSType {MAC_OS, WINDOWS, LINUX, OTHER}
    public enum OSType {MAC_OS, WINDOWS, LINUX, FREEBSD, OTHER}
    private static boolean running;
    private static File baseDir;
@@ -49,6 +49,8 @@
                osType = OSType.WINDOWS;
            } else if (osTypeString.toLowerCase().contains("linux")) {
                osType = OSType.LINUX;
            } else if (osTypeString.toLowerCase().contains("freebsd")) {
                osType = OSType.FREEBSD;
            } else {
                osType = OSType.OTHER;
            }
borgbutler-server/src/main/java/de/micromata/borgbutler/server/ServerConfiguration.java
@@ -18,6 +18,7 @@
    private int port = WEBSERVER_PORT_DEFAULT;
    private boolean webDevelopmentMode = WEB_DEVELOPMENT_MODE_PREF_DEFAULT;
    private BorgVersion borgVersion = new BorgVersion();
    @JsonProperty
    public String getCacheDir() {
        return ButlerCache.getInstance().getCacheDir().getAbsolutePath();
@@ -31,6 +32,17 @@
        return SUPPORTED_LANGUAGES;
    }
    /**
     * @return a clone of this.borgVersion.
     */
    public BorgVersion getBorgVersion() {
        return new BorgVersion().copyFrom(borgVersion);
    }
    BorgVersion _getBorgVersion() {
        return this.borgVersion;
    }
    public static String getApplicationHome() {
        if (applicationHome == null) {
            applicationHome = System.getProperty("applicationHome");
@@ -65,5 +77,7 @@
        super.copyFrom(other);
        this.port = other.port;
        this.webDevelopmentMode = other.webDevelopmentMode;
        this.borgVersion.copyFrom(other.borgVersion);
        this.setBorgCommand(this.borgVersion.getBorgCommand());
    }
}
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ArchivesRest.java
@@ -33,16 +33,16 @@
public class ArchivesRest {
    private static Logger log = LoggerFactory.getLogger(ArchivesRest.class);
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    /**
     *
     * @param repo Name of repository ({@link Repository#getName()}.
     * @param repoName Name of repository ({@link Repository#getName()}.
     * @param archiveId Id or name of archive.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return Repository (including list of archives) as json string.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public String getArchive(@QueryParam("repo") String repoName,
                             @QueryParam("archiveId") String archiveId, @QueryParam("force") boolean force,
                             @QueryParam("prettyPrinter") boolean prettyPrinter) {
@@ -53,9 +53,6 @@
        return JsonUtils.toJson(archive, prettyPrinter);
    }
    @GET
    @Path("filelist")
    @Produces(MediaType.APPLICATION_JSON)
    /**
     *
     * @param archiveId Id or name of archive.
@@ -69,6 +66,9 @@
     * @return Repository (including list of archives) as json string.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Path("filelist")
    @Produces(MediaType.APPLICATION_JSON)
    public String getArchiveFileList(@QueryParam("archiveId") String archiveId,
                                     @QueryParam("searchString") String searchString,
                                     @QueryParam("mode") String mode,
@@ -105,13 +105,13 @@
        return JsonUtils.toJson(items, prettyPrinter);
    }
    @GET
    @Path("/restore")
    @Produces(MediaType.APPLICATION_OCTET_STREAM)
    /**
     * @param archiveId
     * @param fileNumber The fileNumber of the file or directory in the archive served by BorgButler's
     */
    @GET
    @Path("/restore")
    @Produces(MediaType.APPLICATION_OCTET_STREAM)
    public Response restore(@QueryParam("archiveId") String archiveId, @QueryParam("fileNumber") int fileNumber) {
        log.info("Requesting file #" + fileNumber + " of archive '" + archiveId + "'.");
        FileSystemFilter filter = new FileSystemFilter().setFileNumber(fileNumber);
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/BorgRepoConfigsRest.java
New file
@@ -0,0 +1,44 @@
package de.micromata.borgbutler.server.rest;
import de.micromata.borgbutler.cache.ButlerCache;
import de.micromata.borgbutler.config.BorgRepoConfig;
import de.micromata.borgbutler.config.ConfigurationHandler;
import de.micromata.borgbutler.json.JsonUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
@Path("/repoConfig")
public class BorgRepoConfigsRest {
    private static Logger log = LoggerFactory.getLogger(BorgRepoConfigsRest.class);
    /**
     * @param id            id or name of repo.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return {@link BorgRepoConfig} as json string.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public String getRepoConfig(@QueryParam("id") String id, @QueryParam("prettyPrinter") boolean prettyPrinter) {
        BorgRepoConfig repoConfig = ConfigurationHandler.getConfiguration().getRepoConfig(id);
        return JsonUtils.toJson(repoConfig, prettyPrinter);
    }
    @POST
    @Produces(MediaType.TEXT_PLAIN)
    public void setRepoConfig(String jsonConfig) {
        BorgRepoConfig newRepoConfig = JsonUtils.fromJson(BorgRepoConfig.class, jsonConfig);
        BorgRepoConfig repoConfig = ConfigurationHandler.getConfiguration().getRepoConfig(newRepoConfig.getId());
        if (repoConfig == null) {
            log.error("Can't find repo config '" + newRepoConfig.getId() + "'. Can't save new settings.");
            return;
        }
        ButlerCache.getInstance().clearRepoCacheAccess(repoConfig.getRepo());
        ButlerCache.getInstance().clearRepoCacheAccess(newRepoConfig.getRepo());
        repoConfig.copyFrom(newRepoConfig);
        ConfigurationHandler.getInstance().save();
    }
}
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ConfigurationRest.java
@@ -3,6 +3,8 @@
import de.micromata.borgbutler.cache.ButlerCache;
import de.micromata.borgbutler.config.ConfigurationHandler;
import de.micromata.borgbutler.json.JsonUtils;
import de.micromata.borgbutler.server.BorgInstallation;
import de.micromata.borgbutler.server.BorgVersion;
import de.micromata.borgbutler.server.ServerConfiguration;
import de.micromata.borgbutler.server.user.UserData;
import de.micromata.borgbutler.server.user.UserManager;
@@ -17,14 +19,14 @@
public class ConfigurationRest {
    private Logger log = LoggerFactory.getLogger(ConfigurationRest.class);
    @GET
    @Path("config")
    @Produces(MediaType.APPLICATION_JSON)
    /**
     *
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Path("config")
    @Produces(MediaType.APPLICATION_JSON)
    public String getConfig(@QueryParam("prettyPrinter") boolean prettyPrinter) {
        String json = JsonUtils.toJson(ServerConfiguration.get(), prettyPrinter);
        return json;
@@ -37,18 +39,20 @@
        ConfigurationHandler configurationHandler = ConfigurationHandler.getInstance();
        ServerConfiguration config = (ServerConfiguration)configurationHandler.getConfiguration();
        ServerConfiguration srcConfig = JsonUtils.fromJson(ServerConfiguration.class, jsonConfig);
        BorgVersion oldBorgVersion = config.getBorgVersion();
        config.copyFrom(srcConfig);
        BorgInstallation.getInstance().configure(oldBorgVersion);
        configurationHandler.save();
    }
    @GET
    @Path("user")
    @Produces(MediaType.APPLICATION_JSON)
    /**
     *
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Path("user")
    @Produces(MediaType.APPLICATION_JSON)
    public String getUser(@QueryParam("prettyPrinter") boolean prettyPrinter) {
        UserData user = RestUtils.getUser();
        String json = JsonUtils.toJson(user, prettyPrinter);
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/FilesystemBrowserRest.java
New file
@@ -0,0 +1,84 @@
package de.micromata.borgbutler.server.rest;
import de.micromata.borgbutler.json.JsonUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.http.HttpServletRequest;
import javax.swing.*;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import java.io.File;
@Path("/files")
public class FilesystemBrowserRest {
    private Logger log = LoggerFactory.getLogger(FilesystemBrowserRest.class);
    /**
     * Opens a directory browser or file browser on the desktop app and returns the chosen dir/file. Works only if Browser and Desktop app are running
     * on the same host.
     *
     * @param current The current path of file. If not given the directory/file browser starts with the last used directory or user.home.
     * @return The chosen directory path (absolute path).
     */
    @GET
    @Path("/browse-local-filesystem")
    @Produces(MediaType.APPLICATION_JSON)
    public String browseLocalFilesystem(@Context HttpServletRequest requestContext, @QueryParam("current") String current) {
        String msg = RestUtils.checkLocalDesktopAvailable(requestContext);
        if (msg != null) {
            log.info(msg);
            return msg;
        }
        JFileChooser chooser;
        if (StringUtils.isNotBlank(current)) {
            chooser = new JFileChooser(current);
        } else {
            chooser = new JFileChooser();
        }
        chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
        synchronized (FilesystemBrowserRest.class) {
            if (frame == null) {
                frame = new JFrame("BorgButler");
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                JLabel label = new JLabel("Hello World");
                frame.getContentPane().add(label);
                frame.pack();
            }
        }
        frame.setVisible(true);
        frame.setAlwaysOnTop(true);
        int returnCode = chooser.showDialog(frame, "Choose");
        frame.setVisible(false);
        frame.setAlwaysOnTop(false);
        File file = null;
        if (returnCode == JFileChooser.APPROVE_OPTION) {
            file = chooser.getSelectedFile();
        }
        String filename = file != null ? JsonUtils.toJson(file.getAbsolutePath()) : "";
        String result = "{\"directory\":" + filename + "}";
        return result;
    }
    /**
     * @return OK, if the local desktop services such as open file browser etc. are available.
     */
    @GET
    @Path("/local-fileservices-available")
    @Produces(MediaType.TEXT_PLAIN)
    public String browseLocalFilesystem(@Context HttpServletRequest requestContext) {
        String msg = RestUtils.checkLocalDesktopAvailable(requestContext);
        if (msg != null) {
            log.info(msg);
            return msg;
        }
        return "OK";
    }
    private static JFrame frame;
}
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/I18nRest.java
@@ -20,9 +20,6 @@
public class I18nRest {
    private Logger log = LoggerFactory.getLogger(I18nRest.class);
    @GET
    @Path("list")
    @Produces(MediaType.APPLICATION_JSON)
    /**
     *
     * @param requestContext For detecting the user's client locale.
@@ -31,6 +28,9 @@
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Path("list")
    @Produces(MediaType.APPLICATION_JSON)
    public String getList(@Context HttpServletRequest requestContext, @QueryParam("prettyPrinter") boolean prettyPrinter,
                          @QueryParam("keysOnly") boolean keysOnly, @QueryParam("locale") String locale) {
        Locale localeObject;
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/JobsRest.java
@@ -26,10 +26,8 @@
public class JobsRest {
    private static Logger log = LoggerFactory.getLogger(JobsRest.class);
    private static List<JsonJobQueue> testList;
    private static List<JsonJobQueue> testList, oldJobsTestList;
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    /**
     * @param repo If given, only the job queue of the given repo will be returned.
     * @param testMode If true, then a test job list is created.
@@ -37,12 +35,16 @@
     * @return Job queues as json string.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public String getJobs(@QueryParam("repo") String repo,
                          @QueryParam("testMode") boolean testMode,
                          @QueryParam("oldJobs") boolean oldJobs,
                          @QueryParam("prettyPrinter") boolean prettyPrinter) {
        log.debug("getJobs repo=" + repo + ", oldJobs=" + oldJobs);
        if (testMode) {
            // Return dynamic test queue:
            return returnTestList(prettyPrinter);
            return returnTestList(oldJobs, prettyPrinter);
        }
        boolean validRepo = false;
        if (StringUtils.isNotBlank(repo) && !"null".equals(repo) && !"undefined".equals(repo)) {
@@ -51,13 +53,13 @@
        BorgQueueExecutor borgQueueExecutor = BorgQueueExecutor.getInstance();
        List<JsonJobQueue> queueList = new ArrayList<>();
        if (validRepo) { // Get only the queue of the given repo:
            JsonJobQueue queue = getQueue(repo);
            JsonJobQueue queue = getQueue(repo, oldJobs);
            if (queue != null) {
                queueList.add(queue);
            }
        } else { // Get all the queues (of all repos).
            for (String rep : borgQueueExecutor.getRepos()) {
                JsonJobQueue queue = getQueue(rep);
                JsonJobQueue queue = getQueue(rep, oldJobs);
                if (queue != null) {
                    queueList.add(queue);
                }
@@ -66,24 +68,13 @@
        return JsonUtils.toJson(queueList, prettyPrinter);
    }
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path("/statistics")
    /**
     * @return The total number of jobs queued or running (and other statistics): {@link de.micromata.borgbutler.BorgQueueStatistics}.
     * @see JsonUtils#toJson(Object, boolean)
     */
    public String getStatistics() {
        return JsonUtils.toJson(BorgQueueExecutor.getInstance().getStatistics());
    }
    private JsonJobQueue getQueue(String repo) {
    private JsonJobQueue getQueue(String repo, boolean oldJobs) {
        BorgQueueExecutor borgQueueExecutor = BorgQueueExecutor.getInstance();
        BorgRepoConfig repoConfig = ConfigurationHandler.getConfiguration().getRepoConfig(repo);
        if (repoConfig == null) {
            return null;
        }
        List<BorgJob<?>> borgJobList = borgQueueExecutor.getJobListCopy(repoConfig);
        List<BorgJob<?>> borgJobList = borgQueueExecutor.getJobListCopy(repoConfig, oldJobs);
        if (CollectionUtils.isEmpty(borgJobList))
            return null;
        JsonJobQueue queue = new JsonJobQueue().setRepo(repoConfig.getDisplayName());
@@ -95,11 +86,11 @@
        return queue;
    }
    @Path("/cancel")
    @GET
    /**
     * @param uniqueJobNumberString The id of the job to cancel.
     */
    @Path("/cancel")
    @GET
    public void cancelJob(@QueryParam("uniqueJobNumber") String uniqueJobNumberString) {
        Long uniqueJobNumber = null;
        try {
@@ -114,28 +105,31 @@
    /**
     * Only for test purposes and development.
     *
     * @param oldJobs
     * @param prettyPrinter
     * @return
     */
    private String returnTestList(boolean prettyPrinter) {
        if (testList == null) {
            testList = new ArrayList<>();
    private String returnTestList(boolean oldJobs, boolean prettyPrinter) {
        List<JsonJobQueue> list = oldJobs ? oldJobsTestList : testList;
        if (list == null) {
            list = new ArrayList<>();
            long uniqueJobNumber = 100000;
            JsonJobQueue queue = new JsonJobQueue().setRepo("My Computer");
            addTestJob(queue, "info", "my-macbook", 0, 2342)
                    .setUniqueJobNumber(uniqueJobNumber++);
            addTestJob(queue, "list", "my-macbook", -1, -1)
                    .setUniqueJobNumber(uniqueJobNumber++);
            testList.add(queue);
            addTestJob(queue, "info", "my-macbook", 0, 2342, uniqueJobNumber++, oldJobs);
            addTestJob(queue, "list", "my-macbook", -1, -1, uniqueJobNumber++, oldJobs);
            list.add(queue);
            queue = new JsonJobQueue().setRepo("My Server");
            addTestJob(queue, "list", "my-server", 0, 1135821)
                    .setUniqueJobNumber(uniqueJobNumber++);
            addTestJob(queue, "info", "my-server", -1, -1)
                    .setUniqueJobNumber(uniqueJobNumber++);
            testList.add(queue);
        } else {
            for (JsonJobQueue jobQueue : testList) {
            addTestJob(queue, "list", "my-server", 0, 1135821, uniqueJobNumber++, oldJobs);
            addTestJob(queue, "info", "my-server", -1, -1, uniqueJobNumber++, oldJobs);
            list.add(queue);
            if (oldJobs) {
                oldJobsTestList = list;
            } else {
                testList = list;
            }
        } else if (!oldJobs) {
            for (JsonJobQueue jobQueue : list) {
                for (JsonJob job : jobQueue.getJobs()) {
                    if (job.getStatus() != AbstractJob.Status.RUNNING) continue;
                    long current = job.getProgressInfo().getCurrent();
@@ -158,7 +152,7 @@
                }
            }
        }
        return JsonUtils.toJson(testList, prettyPrinter);
        return JsonUtils.toJson(list, prettyPrinter);
    }
    /**
@@ -171,7 +165,7 @@
     * @param total
     * @return
     */
    private JsonJob addTestJob(JsonJobQueue queue, String operation, String host, long current, long total) {
    private JsonJob addTestJob(JsonJobQueue queue, String operation, String host, long current, long total, long uniqueNumber, boolean oldJobs) {
        ProgressInfo progressInfo = new ProgressInfo()
                .setCurrent(current)
                .setTotal(total);
@@ -196,6 +190,10 @@
        if (queue.getJobs() == null) {
            queue.setJobs(new ArrayList<>());
        }
        job.setUniqueJobNumber(uniqueNumber);
        if (oldJobs) {
            job.setStatus(uniqueNumber % 2 == 0 ? AbstractJob.Status.CANCELLED : AbstractJob.Status.DONE);
        }
        queue.getJobs().add(job);
        return job;
    }
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ReposRest.java
@@ -19,15 +19,15 @@
public class ReposRest {
    private static Logger log = LoggerFactory.getLogger(ReposRest.class);
    @GET
    @Path("list")
    @Produces(MediaType.APPLICATION_JSON)
    /**
     *
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return A list of repositories of type {@link BorgRepository}.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Path("list")
    @Produces(MediaType.APPLICATION_JSON)
    public String getList(@QueryParam("prettyPrinter") boolean prettyPrinter) {
        List<Repository> repositories = ButlerCache.getInstance().getAllRepositories();
        if (CollectionUtils.isEmpty(repositories)) {
@@ -36,32 +36,31 @@
        return JsonUtils.toJson(repositories, prettyPrinter);
    }
    @GET
    @Path("repo")
    @Produces(MediaType.APPLICATION_JSON)
    /**
     *
     * @param id id or name of repo.
     * @param force If true, a reload of all repositories is forced.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return Repository (without list of archives) as json string.
     * @return {@link Repository} (without list of archives) as json string.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Path("repo")
    @Produces(MediaType.APPLICATION_JSON)
    public String getRepo(@QueryParam("id") String id, @QueryParam("prettyPrinter") boolean prettyPrinter) {
        Repository repository = ButlerCache.getInstance().getRepository(id);
        return JsonUtils.toJson(repository, prettyPrinter);
    }
    @GET
    @Path("repoArchiveList")
    @Produces(MediaType.APPLICATION_JSON)
    /**
     *
     * @param id id or name of repo.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return Repository (including list of archives) as json string.
     * @return {@link Repository} (including list of archives) as json string.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Path("repoArchiveList")
    @Produces(MediaType.APPLICATION_JSON)
    public String getRepoArchiveList(@QueryParam("id") String id, @QueryParam("force") boolean force,
                                     @QueryParam("prettyPrinter") boolean prettyPrinter) {
        if (force) {
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/SystemInfo.java
New file
@@ -0,0 +1,25 @@
package de.micromata.borgbutler.server.rest;
import de.micromata.borgbutler.BorgQueueStatistics;
import de.micromata.borgbutler.server.BorgVersion;
import lombok.Getter;
import lombok.Setter;
/**
 * Statistics of all the job queues, especially the number of total queued and running jobs.
 * This is used e. g. by the client for showing a badge near to the menu entry "job monitor" with the number
 * of Jobs in the queues.
 */
public class SystemInfo {
    @Getter
    @Setter
    private BorgQueueStatistics queueStatistics;
    @Getter
    @Setter
    private boolean configurationOK;
    @Getter
    @Setter
    private BorgVersion borgVersion;
}
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/SystemInfoRest.java
New file
@@ -0,0 +1,34 @@
package de.micromata.borgbutler.server.rest;
import de.micromata.borgbutler.BorgQueueExecutor;
import de.micromata.borgbutler.json.JsonUtils;
import de.micromata.borgbutler.server.BorgVersion;
import de.micromata.borgbutler.server.ServerConfiguration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
@Path("/system")
public class SystemInfoRest {
    private static Logger log = LoggerFactory.getLogger(SystemInfoRest.class);
    /**
     * @return The total number of jobs queued or running (and other statistics): {@link de.micromata.borgbutler.BorgQueueStatistics}.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @Path("info")
    public String getStatistics() {
        BorgVersion borgVersion = ServerConfiguration.get().getBorgVersion();
        SystemInfo systemInfonfo = new SystemInfo()
                .setQueueStatistics(BorgQueueExecutor.getInstance().getStatistics())
                .setConfigurationOK(borgVersion.isVersionOK())
                .setBorgVersion(borgVersion);
        return JsonUtils.toJson(systemInfonfo);
    }
}
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/VersionRest.java
@@ -22,15 +22,15 @@
public class VersionRest {
    private Logger log = LoggerFactory.getLogger(VersionRest.class);
    @GET
    @Path("version")
    @Produces(MediaType.APPLICATION_JSON)
    /**
     *
     * @param requestContext For detecting the user's client locale.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @see JsonUtils#toJson(Object, boolean)
     */
    @GET
    @Path("version")
    @Produces(MediaType.APPLICATION_JSON)
    public String getVersion(@Context HttpServletRequest requestContext, @QueryParam("prettyPrinter") boolean prettyPrinter) {
        UserData user = RestUtils.getUser();
        String language = Languages.asString(user.getLocale());
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/queue/JsonJob.java
@@ -38,6 +38,15 @@
    @Getter
    @Setter
    private String[] environmentVariables;
    @Getter
    @Setter
    private String createTime;
    @Getter
    @Setter
    private String startTime;
    @Getter
    @Setter
    private String stopTime;
    public JsonJob() {
    }
@@ -55,6 +64,9 @@
        this.commandLineAsString = borgJob.getCommandLineAsString();
        this.description = borgJob.getDescription();
        environmentVariables = borgJob.getCommand().getRepoConfig().getEnvironmentVariables();
        this.createTime = borgJob.getCreateTime();
        this.startTime = borgJob.getStartTime();
        this.stopTime = borgJob.getStopTime();
    }
    /**
borgbutler-server/src/main/resources/log4j.properties
@@ -14,7 +14,7 @@
log4j.appender.file=org.apache.log4j.RollingFileAppender
log4j.appender.file.File=merlin.log
log4j.appender.file.File=borgbutler.log
log4j.appender.file.MaxFileSize=10MB
log4j.appender.file.MaxBackupIndex=5
log4j.appender.file.layout=org.apache.log4j.PatternLayout
borgbutler-server/src/test/java/de/micromata/borgbutler/server/BorgInstallationTest.java
New file
@@ -0,0 +1,38 @@
package de.micromata.borgbutler.server;
import de.micromata.borgbutler.config.ConfigurationHandler;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import static org.junit.jupiter.api.Assertions.*;
public class BorgInstallationTest {
    private Logger log = LoggerFactory.getLogger(BorgInstallationTest.class);
    @Test
    void foo() throws Exception {
        ConfigurationHandler.getConfiguration().setBorgCommand(null);
        BorgInstallation borgInstallation = BorgInstallation.getInstance();
        borgInstallation.initialize();
        ConfigurationHandler.getConfiguration().setBorgCommand("borg");
        borgInstallation.initialize();
    }
    @Test
    void downloadTest() {
        String version = new BorgVersion().getBinariesDownloadVersion();
        checkDownload(RunningMode.OSType.LINUX, "borg-linux64-" + version);
        checkDownload(RunningMode.OSType.MAC_OS, "borg-macosx64-" + version);
        checkDownload(RunningMode.OSType.FREEBSD, "borg-freebsd64-" + version);
        assertNull(BorgInstallation.getInstance().download(RunningMode.OSType.WINDOWS));
    }
    private void checkDownload(RunningMode.OSType osType, String expectedName) {
        File file = BorgInstallation.getInstance().download(osType);
        assertEquals(expectedName, file.getName());
        assertTrue(file.canExecute());
    }
}
borgbutler-webapp/src/components/general/forms/FormComponents.jsx
@@ -3,8 +3,9 @@
import classNames from 'classnames';
import {FormFeedback, Input, UncontrolledTooltip} from 'reactstrap';
import {FormCheckbox} from "./FormCheckbox";
import {FormRadioButton} from "./FormRadioButton";
import {FormButton} from "./FormButton";
import {FormSelect, FormOption} from "./FormSelect";
import {FormOption, FormSelect} from "./FormSelect";
import {revisedRandId} from "../../../utilities/global";
import I18n from "../translation/I18n";
@@ -80,10 +81,14 @@
        </UncontrolledTooltip>;
    }
    const {fieldLength, className, hint, hintPlacement, id, ...other} = props;
    let myClassName = className;
    if (fieldLength > 0) {
        myClassName = classNames(`col-sm-${props.fieldLength}`, className);
    }
    return (
        <React.Fragment>
            <Input id={targetId}
                   className={classNames(`col-sm-${props.fieldLength}`, className)}
                   className={myClassName}
                   {...other}
            />
            {tooltip}
@@ -113,7 +118,6 @@
    name: '',
    hint: null,
    hintPlacement: 'top',
    fieldLength: 10,
    value: '',
    min: null,
    max: null,
@@ -158,9 +162,9 @@
const FormLabelField = (props) => {
    const forId = props.children.props.id || props.htmlFor || revisedRandId();
    const forId = props.children.props.id || props.children.props.name || props.htmlFor || revisedRandId();
    return (
        <FormGroup>
        <FormGroup className={props.className}>
            <FormLabel length={props.labelLength} htmlFor={forId}>
                {props.label}
            </FormLabel>
@@ -173,6 +177,7 @@
FormLabelField.propTypes = {
    id: PropTypes.string,
    className: PropTypes.string,
    htmlFor: PropTypes.string,
    validationMessage: PropTypes.string,
    labelLength: PropTypes.number,
@@ -184,6 +189,7 @@
FormLabelField.defaultProps = {
    id: null,
    className: null,
    htmlFor: null,
    validationMessage: null,
    labelLength: 2,
@@ -202,6 +208,7 @@
            label={props.label}
            hint={props.hint}
            validationState={props.validationState}
            className={props.className}
        >
            <FormInput
                id={props.id}
@@ -224,6 +231,7 @@
FormLabelInputField.propTypes = {
    id: PropTypes.string,
    label: PropTypes.node,
    className: PropTypes.node,
    labelLength: PropTypes.number,
    fieldLength: PropTypes.number,
    hint: PropTypes.string,
@@ -241,6 +249,7 @@
FormLabelInputField.defaultProps = {
    id: null,
    className: null,
    label: '',
    labelLength: 2,
    fieldLength: 10,
@@ -291,6 +300,7 @@
    FormSelect,
    FormOption,
    FormCheckbox,
    FormRadioButton,
    FormLabelInputField,
    FormFieldset,
    FormButton,
borgbutler-webapp/src/components/general/forms/FormRadioButton.jsx
New file
@@ -0,0 +1,62 @@
import React from 'react';
import PropTypes from 'prop-types';
import {UncontrolledTooltip} from 'reactstrap';
import {revisedRandId} from "../../../utilities/global";
import classNames from 'classnames';
import I18n from "../translation/I18n";
class FormRadioButton extends React.Component {
    _id = this.props.id || revisedRandId();
    render() {
        const {id, className, labelKey, label, hint, hintKey, ...other} = this.props;
        let tooltip = null;
        let hintId = null;
        if (hint || hintKey) {
            hintId = `hint-${this._id}`;
            tooltip =
                <UncontrolledTooltip placement="right" target={hintId}>
                    {hintKey ? <I18n name={hintKey}/> : hint}
                </UncontrolledTooltip>;
        }
        let labelNode = <label
            className={'custom-control-label'}
            htmlFor={this._id}>
            {labelKey ? <I18n name={labelKey}/> : this.props.label}
        </label>;
        return (
            <React.Fragment>
                <div className="custom-control custom-radio custom-control-inline" id={hintId}>
                    <input type="radio"
                           id={this._id}
                           className={classNames('custom-control-input', className)}
                           {...other}
                    />
                    {labelNode}
                </div>
                {tooltip}
            </React.Fragment>
        );
    }
}
FormRadioButton.propTypes = {
    id: PropTypes.string,
    name: PropTypes.string,
    checked: PropTypes.bool,
    onChange: PropTypes.func,
    hint: PropTypes.string,
    label: PropTypes.node,
    labelKey: PropTypes.string
};
FormRadioButton.defaultProps = {
    checked: false,
    onChange: null
};
export {
    FormRadioButton
};
borgbutler-webapp/src/components/general/forms/FormSelect.jsx
@@ -12,11 +12,15 @@
            {props.hint}
        </UncontrolledTooltip>;
    }
    const {className, hint, hintPlacement, id, ...other} = props;
    const {fieldLength, className, hint, hintPlacement, id, ...other} = props;
    let myClassName = className;
    if (fieldLength > 0) {
        myClassName = classNames(`col-sm-${props.fieldLength}`, className);
    }
    return (
        <React.Fragment>
            <select id={targetId}
                    className={classNames('custom-select form-control form-control-sm mr-1', className)}
                    className={classNames('custom-select form-control form-control-sm mr-1', myClassName)}
                    {...other}
            >
                {props.children}
@@ -31,6 +35,7 @@
    value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
    name: PropTypes.string,
    onChange: PropTypes.func,
    fieldLength: PropTypes.number,
    hint: PropTypes.string,
    hintPlacement: PropTypes.oneOf(['right', 'top']),
    children: PropTypes.node,
borgbutler-webapp/src/components/views/archives/ArchiveView.jsx
@@ -72,7 +72,9 @@
            />;
        } else if (this.state.archive) {
            pageHeader = <React.Fragment>
                <Link to={`/repoArchives/${this.state.repoId}`}> {archive.repoDisplayName}</Link> - {archive.name}
                <Link to={'/repos'}> Repositories</Link> -
                <Link
                    to={`/repoArchives/${this.state.repoId}/${archive.repoDisplayName}`}> {archive.repoDisplayName}</Link> - {archive.name}
                <div
                    className={'btn btn-outline-primary refresh-button-right'}
                    onClick={this.toggleModal}
borgbutler-webapp/src/components/views/config/ConfigurationPage.jsx
@@ -8,6 +8,7 @@
import ConfigAccountTab from "./ConfigurationAccountTab";
import ConfigServerTab from "./ConfigurationServerTab";
import LoadingOverlay from '../../general/loading/LoadingOverlay';
import ConfirmModal from '../../general/modal/ConfirmModal';
class ConfigurationPage
    extends React
@@ -20,11 +21,13 @@
        this.state = {
            activeTab: '1',
            loading: false,
            reload: false
            reload: false,
            confirmModal: false
        };
        this.onSave = this.onSave.bind(this);
        this.onCancel = this.onCancel.bind(this);
        this.toggleModal = this.toggleModal.bind(this);
    }
    toggleTab = tab => () => {
@@ -58,6 +61,12 @@
        this.setReload();
    }
    toggleModal() {
        this.setState({
            confirmModal: !this.state.confirmModal
        })
    }
    render() {
        // https://codepen.io/_arpy/pen/xYoyPW
        if (this.state.reload) {
@@ -65,6 +74,17 @@
        }
        return (
            <React.Fragment>
                <ConfirmModal
                    onConfirm={ConfigServerTab.clearAllCaches}
                    title={'Are you sure?'}
                    toggle={this.toggleModal}
                    open={this.state.confirmModal}
                >
                    Do you really want to clear all caches? All Archive file lists and caches for repo and archive
                    information will be cleared.
                    <br/>
                    This is a safe option but it may take some time to re-fill the caches (on demand) again.
                </ConfirmModal>
                <PageHeader><I18n name={'configuration'}/></PageHeader>
                <Nav tabs>
                    <NavLink
@@ -92,6 +112,8 @@
                </TabContent>
                <FormGroup>
                    <FormField length={12}>
                        <FormButton id={'clearAllCaches'} onClick={this.toggleModal}> Clear all caches
                        </FormButton>
                        <FormButton onClick={this.onCancel}
                                    hintKey="configuration.cancel.hint"><I18n name={'common.cancel'}/>
                        </FormButton>
borgbutler-webapp/src/components/views/config/ConfigurationServerTab.jsx
@@ -1,10 +1,20 @@
import React from 'react';
import {FormButton, FormCheckbox, FormLabelField, FormLabelInputField} from '../../general/forms/FormComponents';
import {Alert} from 'reactstrap';
import {
    FormCheckbox,
    FormField,
    FormGroup,
    FormInput,
    FormLabel,
    FormLabelField,
    FormLabelInputField,
    FormOption,
    FormSelect
} from '../../general/forms/FormComponents';
import {getRestServiceUrl} from '../../../utilities/global';
import I18n from '../../general/translation/I18n';
import ErrorAlertGenericRestFailure from '../../general/ErrorAlertGenericRestFailure';
import Loading from '../../general/Loading';
import ConfirmModal from '../../general/modal/ConfirmModal';
class ConfigServerTab extends React.Component {
    loadConfig = () => {
@@ -25,6 +35,8 @@
            .then((data) => {
                this.setState({
                    loading: false,
                    borgBinary: data.borgVersion.borgBinary,
                    borgCommand: data.borgVersion.borgCommand,
                    ...data
                })
            })
@@ -48,13 +60,13 @@
            showDemoRepos: true,
            maxArchiveContentCacheCapacityMb: 100,
            redirect: false,
            confirmModal: false
            borgCommand: null,
            borgBinary: null
        };
        this.handleTextChange = this.handleTextChange.bind(this);
        this.handleCheckboxChange = this.handleCheckboxChange.bind(this);
        this.loadConfig = this.loadConfig.bind(this);
        this.toggleModal = this.toggleModal.bind(this);
    }
    componentDidMount() {
@@ -73,9 +85,13 @@
    save() {
        var config = {
            port: this.state.port,
            maxArchiveContentCacheCapacityMb : this.state.maxArchiveContentCacheCapacityMb,
            maxArchiveContentCacheCapacityMb: this.state.maxArchiveContentCacheCapacityMb,
            webDevelopmentMode: this.state.webDevelopmentMode,
            showDemoRepos: this.state.showDemoRepos
            showDemoRepos: this.state.showDemoRepos,
            borgVersion: {
                borgCommand: this.state.borgCommand,
                borgBinary: this.state.borgBinary
            }
        };
        return fetch(getRestServiceUrl("configuration/config"), {
            method: 'POST',
@@ -96,12 +112,6 @@
        })
    }
    toggleModal() {
        this.setState({
            confirmModal: !this.state.confirmModal
        })
    }
    render() {
        if (this.state.loading) {
            return <Loading/>;
@@ -110,30 +120,47 @@
        if (this.state.failed) {
            return <ErrorAlertGenericRestFailure handleClick={this.loadConfig}/>;
        }
        const borgVersion = this.state.borgVersion;
        let borgInfoColor = 'success';
        let borgInfoMessage = `Borg version '${borgVersion.version}' is OK.`;
        if (!borgVersion.versionOK) {
            borgInfoColor = 'danger';
            borgInfoMessage = borgVersion.statusMessage;
        }
        return (
            <div>
                <ConfirmModal
                    onConfirm={ConfigServerTab.clearAllCaches}
                    title={'Are you sure?'}
                    toggle={this.toggleModal}
                    open={this.state.confirmModal}
                >
                    Do you really want to clear all caches? All Archive file lists and caches for repo and archive
                    information will be cleared.
                    <br/>
                    This is a safe option but it may take some time to re-fill the caches (on demand) again.
                </ConfirmModal>
            <React.Fragment>
                <form>
                    <FormLabelField>
                        <FormButton id={'clearAllCaches'} onClick={this.toggleModal}> Clear all caches
                        </FormButton>
                    </FormLabelField>
                    <FormGroup>
                        <FormLabel>{'Borg command'}</FormLabel>
                        <FormField length={2}>
                            <FormSelect
                                value={this.state.borgBinary}
                                name={'borgBinary'}
                                onChange={this.handleTextChange}
                                hint={`Choose your OS and BorgButler will download and use a ready to run borg binary from ${borgVersion.binariesDownloadUrl} or choose a manual installed version.`}
                            >
                                {borgVersion.borgBinaries
                                    .map((binary, index) => <FormOption label={binary[1]} value={binary[0]}
                                                                        key={index}/>)}
                                <FormOption label={'Manual'} value={'manual'}/>
                            </FormSelect>
                        </FormField>
                        <FormInput fieldLength={8} name={'borgCommand'} value={this.state.borgCommand}
                                   onChange={this.handleTextChange}
                                   placeholder="Enter path of borg command"
                                   disabled={this.state.borgBinary !== "manual"}/>
                    </FormGroup>
                    <FormGroup>
                        <FormField length={4}/>
                        <Alert className={'col-sm-8'} color={borgInfoColor}>
                            {borgInfoMessage}
                        </Alert>
                    </FormGroup>
                    <FormLabelInputField label={'Port'} fieldLength={2} type="number" min={0} max={65535}
                                         step={1}
                                         name={'port'} value={this.state.port}
                                         onChange={this.handleTextChange}
                                         placeholder="Enter port" />
                                         placeholder="Enter port"/>
                    <FormLabelInputField label={'Maximum disc capacity (MB)'} fieldLength={2} type="number" min={50}
                                         max={10000}
                                         step={50}
@@ -141,21 +168,21 @@
                                         value={this.state.maxArchiveContentCacheCapacityMb}
                                         onChange={this.handleTextChange}
                                         placeholder="Enter maximum Capacity"
                                         hint={`Limits the cache size of archive file lists in the local cache directory: ${this.state.cacheDir}`} />
                                         hint={`Limits the cache size of archive file lists in the local cache directory: ${this.state.cacheDir}`}/>
                    <FormLabelField label={'Show demo repositories'} fieldLength={2}>
                        <FormCheckbox checked={this.state.showDemoRepos}
                                      hint={'If true, some demo repositories are shown for testing the functionality of BorgButler without any further configuration and running borg backups.'}
                                      name="showDemoRepos"
                                      onChange={this.handleCheckboxChange} />
                                      onChange={this.handleCheckboxChange}/>
                    </FormLabelField>
                    <FormLabelField label={<I18n name={'configuration.webDevelopmentMode'} />} fieldLength={2}>
                    <FormLabelField label={<I18n name={'configuration.webDevelopmentMode'}/>} fieldLength={2}>
                        <FormCheckbox checked={this.state.webDevelopmentMode}
                                      hintKey={'configuration.webDevelopmentMode.hint'}
                                      name="webDevelopmentMode"
                                      onChange={this.handleCheckboxChange} />
                                      onChange={this.handleCheckboxChange}/>
                    </FormLabelField>
                </form>
            </div>
            </React.Fragment>
        );
    }
}
borgbutler-webapp/src/components/views/jobs/Job.css
@@ -7,4 +7,12 @@
.job-progress {
    width: calc(100% - 3em);
    margin-top: -10px;
}
.job-progress div.progress {
    margin: 0 .75rem .375rem;
}
.onclick {
    cursor: pointer;
}
borgbutler-webapp/src/components/views/jobs/Job.jsx
@@ -39,6 +39,22 @@
    render() {
        let content = undefined;
        let job = this.props.job;
        let cancelDisabled = undefined;
        let times = ' (created: ' + job.createTime;
        if (job.startTime) {
            times += ', started: ' + job.startTime;
        }
        if (job.stopTime) {
            times += ', ended: ' + job.stopTime;
        }
        times += ')';
        if ((job.status !== 'RUNNING' && job.status !== 'QUEUED') || job.cancellationRequested) {
            cancelDisabled = true;
        }
        let cancelButton = <div className="job-cancel"><Button color={'danger'}
                                                               onClick={() => this.cancelJob(job.uniqueJobNumber)}
                                                               disabled={cancelDisabled}><IconCancel/></Button>
        </div>;
        if (job.status === 'RUNNING') {
            let progressPercent = 100;
            let color = 'success';
@@ -49,13 +65,18 @@
                progressPercent = job.progressPercent;
            }
            content = <Progress animated color={color} value={progressPercent}>{job.progressText}</Progress>;
        } else if (job.status === 'CANCELLED') {
            content = <Progress color={'warning'} value={100}>{job.status}</Progress>
            cancelButton = '';
        } else if (job.status === 'FAILED') {
            content = <Progress color={'danger'} value={100}>{job.status}</Progress>
            cancelButton = '';
        } else if (job.status === 'DONE') {
            content = <Progress color={'success'} value={100}>{job.status}</Progress>
            cancelButton = '';
        } else {
            content = <Progress color={'info'} value={100}>{job.status}</Progress>
        }
        let cancelDisabled = undefined;
        if ((job.status !== 'RUNNING' && job.status !== 'QUEUED') || job.cancellationRequested) {
            cancelDisabled = true;
        }
        let environmentVariables = '';
        if (job.environmentVariables && Array.isArray(job.environmentVariables)) {
            environmentVariables = job.environmentVariables.map((variable, index) => <React.Fragment key={index}>
@@ -70,10 +91,7 @@
                        <Button color="link" onClick={this.toggle}>{job.description}</Button>
                        {content}
                    </div>
                    <div className="job-cancel"><Button color={'danger'}
                                                        onClick={() => this.cancelJob(job.uniqueJobNumber)}
                                                        disabled={cancelDisabled}><IconCancel/></Button>
                    </div>
                    {cancelButton}
                </div>
                <Collapse isOpen={this.state.collapse}>
                    <Card>
@@ -82,7 +100,7 @@
                                <tbody>
                                <tr>
                                    <th>Status</th>
                                    <td>{job.status}</td>
                                    <td>{job.status} {times}</td>
                                </tr>
                                <tr>
                                    <th>Command line</th>
borgbutler-webapp/src/components/views/jobs/JobMonitorPanel.jsx
@@ -1,19 +1,21 @@
import React from 'react';
import {Button} from 'reactstrap';
import {Button, Collapse} from 'reactstrap';
import {getRestServiceUrl, isDevelopmentMode} from '../../../utilities/global';
import JobQueue from './JobQueue';
import ErrorAlert from '../archives/ArchiveView';
import ErrorAlert from '../../general/ErrorAlert';
import PropTypes from 'prop-types';
class JobMonitorPanel extends React.Component {
    state = {
        isFetching: false,
        testMode: false
        isFetchingOldJobs: false,
        testMode: false,
        collapseOldJobs: false
    };
    componentDidMount = () => {
        this.fetchQueues();
        this.interval = setInterval(() => this.fetchQueues(), 2000);
        this.fetchQueues(false);
        this.interval = setInterval(() => this.fetchJobs(), 2000);
    };
    componentWillUnmount() {
@@ -26,14 +28,39 @@
        });
    }
    fetchQueues = () => {
    toggleOldJobs() {
        if (!this.state.collapseOldJobs) {
            this.fetchQueues(true);
        }
        this.setState({collapseOldJobs: !this.state.collapseOldJobs});
    }
    fetchJobs() {
        if (!this.state.isFetching) { // Don't run twice at the same time
            this.fetchQueues(false);
        }
        if (this.state.collapseOldJobs && !this.state.isFetchingOldJobs) {
            this.fetchQueues(true);
        }
    }
    fetchQueues = (oldJobs) => {
        let queuesVar = 'queues';
        let isFetchingVar = 'isFetching';
        let failedVar = 'failed';
        if (oldJobs) {
            queuesVar = 'oldJobsQueues';
            isFetchingVar = 'isFetchingOldJobs';
            failedVar = 'oldJobsFailed';
        }
        this.setState({
            isFetching: true,
            failed: false
            [isFetchingVar]: true,
            [failedVar]: false
        });
        fetch(getRestServiceUrl('jobs', {
            repo: this.props.repo,
            testMode: this.state.testMode
            testMode: this.state.testMode,
            oldJobs: oldJobs
        }), {
            method: 'GET',
            headers: {
@@ -46,11 +73,11 @@
                    return queue;
                });
                this.setState({
                    isFetching: false,
                    queues
                    [isFetchingVar]: false,
                    [queuesVar]: queues
                });
            })
            .catch(() => this.setState({isFetching: false, failed: true}));
            .catch(() => this.setState({[isFetchingVar]: false, [failedVar]: true}));
    };
    render() {
@@ -60,7 +87,7 @@
            content = <i>Loading...</i>;
        } else if (this.state.failed) {
            content = <ErrorAlert
                title={'Cannot load Repositories'}
                title={'Cannot load job queues'}
                description={'Something went wrong during contacting the rest api.'}
                action={{
                    handleClick: this.fetchQueues,
@@ -77,16 +104,50 @@
                            key={queue.repo}
                        />)}
                </React.Fragment>;
            } else if (isDevelopmentMode() && !this.props.embedded) {
                content = <React.Fragment>No jobs are running or queued.<br/><br/>
                    <Button color="primary" onClick={this.toggleTestMode}>Test mode</Button>
                </React.Fragment>
            } else {
                content = <React.Fragment>No jobs are running or queued.</React.Fragment>
            }
        }
        let oldJobs = '';
        if (this.state.isFetchingOldJobs && !this.state.oldJobsQueues) {
            oldJobs = <i>Loading...</i>;
        } else if (this.state.oldJobsFailed) {
            oldJobs = <ErrorAlert
                title={'Cannot load old job queues'}
                description={'Something went wrong during contacting the rest api.'}
                action={{
                    handleClick: this.fetchQueues,
                    title: 'Try again'
                }}
            />;
        } else if (this.state.oldJobsQueues) {
            if (this.state.oldJobsQueues.length > 0) {
                oldJobs = <React.Fragment>
                    {this.state.oldJobsQueues
                        .map((queue) => <JobQueue
                            embedded={this.props.embedded}
                            queue={queue}
                            key={queue.repo}
                        />)}
                </React.Fragment>
            } else {
                oldJobs = <React.Fragment>No old jobs available.</React.Fragment>
            }
        }
        let testContent = '';
        if (isDevelopmentMode() && !this.props.embedded) {
            testContent = <React.Fragment><br/><br/><br/><Button className="btn-outline-info" onClick={this.toggleTestMode}>Test mode</Button></React.Fragment>;
        }
        return <React.Fragment>
            {content}
            <h5 className={'onclick'} onClick={this.toggleOldJobs}>Show old jobs
            </h5>
            <Collapse isOpen={this.state.collapseOldJobs}>
                {oldJobs}
            </Collapse>
            {testContent}
        </React.Fragment>;
    }
@@ -95,15 +156,18 @@
        this.fetchQueues = this.fetchQueues.bind(this);
        this.toggleTestMode = this.toggleTestMode.bind(this);
        this.toggleOldJobs = this.toggleOldJobs.bind(this);
    }
}
JobMonitorPanel.propTypes = {
JobMonitorPanel
    .propTypes = {
    embedded: PropTypes.bool,
    repo: PropTypes.string
};
JobMonitorPanel.defaultProps = {
JobMonitorPanel
    .defaultProps = {
    embedded: true,
    repo: null
};
borgbutler-webapp/src/components/views/jobs/JobQueue.jsx
@@ -1,25 +1,45 @@
import React from 'react';
import {Card, CardBody, CardHeader, ListGroup} from 'reactstrap';
import {Card, CardBody, CardHeader, Collapse, ListGroup} from 'reactstrap';
import Job from "./Job";
function JobQueue({queue, embedded}) {
    return (
        <div>
            <Card>
                <CardHeader>Job queue: {queue.repo}</CardHeader>
                <CardBody>
                    <ListGroup>
                        {queue.jobs
                            .map((job, index) => <Job
                                embedded={embedded}
                                job={job}
                                key={job.uniqueJobNumber}
                            />)}
                    </ListGroup>
                </CardBody>
            </Card>
        </div>
    );
class JobQueue extends React.Component {
    state = {
        collapsed: true
    };
    toggle() {
        this.setState({collapsed: !this.state.collapsed});
    }
    render() {
        const queue = this.props.queue;
        return (
            <div>
                <Card>
                    <CardHeader className={'onclick'} onClick={this.toggle}>Job queue: {queue.repo}</CardHeader>
                    <Collapse isOpen={this.state.collapsed}>
                        <CardBody>
                            <ListGroup>
                                {queue.jobs
                                    .map((job, index) => <Job
                                        embedded={this.props.embedded}
                                        job={job}
                                        key={job.uniqueJobNumber}
                                    />)}
                            </ListGroup>
                        </CardBody>
                    </Collapse>
                </Card>
            </div>
        );
    }
    constructor(props) {
        super(props);
        this.toggle = this.toggle.bind(this);
    }
}
export default JobQueue;
borgbutler-webapp/src/components/views/repos/ConfigureRepoPage.jsx
New file
@@ -0,0 +1,252 @@
import React from 'react';
import {Link} from 'react-router-dom'
import {
    FormButton,
    FormField,
    FormGroup,
    FormInput,
    FormLabel,
    FormLabelInputField,
    FormOption,
    FormRadioButton,
    FormSelect
} from '../../general/forms/FormComponents';
import I18n from "../../general/translation/I18n";
import {PageHeader} from "../../general/BootstrapComponents";
class ConfigureRepoPage extends React.Component {
    constructor(props) {
        super(props);
        this.handleTextChange = this.handleTextChange.bind(this);
        this.handleRepoTextChange = this.handleRepoTextChange.bind(this);
        this.handleCheckboxChange = this.handleCheckboxChange.bind(this);
        this.state = {
            repoConfig: {},
            mode: 'existingRepo',
            localRemote: 'local',
            encryption: 'repokey',
            passwordMethod: 'passwordCommand',
            passwordCreate: null
        };
    }
    handleTextChange = event => {
        event.preventDefault();
        this.setState({[event.target.name]: event.target.value});
        if (event.target.name === 'passwordMethod') {
            const value = event.target.value;
            var passwordCommand = null;
            var passwordCreate = null;
            if (value === 'passwordFile') {
                passwordCommand = 'cat ~/.borg-passphrase';
                passwordCreate = <React.Fragment>
                    Create a file with a password in it in your home directory and use permissions to keep anyone else
                    from
                    reading it:<br/>
                    <div className="command-line">head -c 1024 /dev/urandom | base64 > ~/.borg-passphrase<br/>
                        chmod 400 ~/.borg-passphrase
                    </div>
                </React.Fragment>;
            } else if (value === 'macos-keychain') {
                passwordCommand = 'security find-generic-password -a $USER -s borg-passphrase';
                passwordCreate = <React.Fragment>
                    Generate a passphrase and use security to save it to your login (default) keychain:<br/>
                    <div className="command-line">security add-generic-password -D secret -U -a $USER -s borg-passphrase
                        -w $(head -c 1024 /dev/urandom | base64)
                    </div>
                </React.Fragment>;
            } else if (value === 'gnome-keyring') {
                passwordCommand = 'secret-tool lookup borg-repository repo-name';
                passwordCreate = <React.Fragment>
                    First ensure libsecret-tools, gnome-keyring and libpam-gnome-keyring are installed. If
                    libpam-gnome-keyring wasn’t already installed, ensure it runs on login:<br/>
                    <div className="command-line">sudo sh -c "echo session optional pam_gnome_keyring.so auto_start >>
                        /etc/pam.d/login"<br/>
                        sudo sh -c "echo password optional pam_gnome_keyring.so >> /etc/pam.d/passwd"<br/>
                        # you may need to relogin afterwards to activate the login keyring
                    </div>
                    Then add a secret to the login keyring:
                    <div className="command-line">head -c 1024 /dev/urandom | base64 | secret-tool store borg-repository
                        repo-name --label="Borg Passphrase"</div>
                </React.Fragment>;
            } else if (value === 'kwallet') {
                passwordCommand = 'kwalletcli -e borg-passphrase -f Passwords';
                passwordCreate = <React.Fragment>
                    Ensure kwalletcli is installed, generate a passphrase, and store it in your “wallet”:<br/>
                    <div className="command-line">head -c 1024 /dev/urandom | base64 | kwalletcli -Pe borg-passphrase -f
                        Passwords
                    </div>
                </React.Fragment>;
            }
            if (passwordCommand) {
                this.setState({repoConfig: {...this.state.repoConfig, 'passwordCommand': passwordCommand}})
            }
            this.setState({'passwordCreate': passwordCreate});
        }
    }
    handleRepoTextChange = event => {
        event.preventDefault();
        this.setState({repoConfig: {...this.state.repoConfig, [event.target.name]: event.target.value}});
    }
    handleCheckboxChange = event => {
        this.setState({[event.target.name]: event.target.value});
        /*if (event.target.name === 'mode' && event.target.value === 'existingRepo'
            && this.state.passwordMethod !== 'passwordCommand' && this.state.passwordMethod !== 'passphrase') {
            // Other options such as Mac OS key chain isn't available for existing repos:
            this.setState({passwordMethod: 'passwordCommand'});
        }*/
    }
    render() {
        const repoConfig = this.state.repoConfig;
        var passwordMethods = [['password-command', 'Password command'],
            ['passwordFile', 'Password file'],
            ['macos-keychain', 'Mac OS X keychain'],
            ['gnome-keyring', 'GNOME keyring'],
            ['kwallet', 'KWallet'],
            ['passphrase', 'Passphrase (not recommended)']
        ];
        let repoPlaceHolder = 'Enter the repo used by Borg.';
        if (this.state.mode === 'initNewRepo' && this.state.localRemote === 'remote') {
            repoPlaceHolder = 'Enter the remote path of the repo, such as user@hostname:backup.';
        }
        let repoFieldLength = 10;
        let browseButton = null;
        if (this.state.localRemote === 'local') {
            repoFieldLength = 9;
            browseButton = <FormButton onClick={null}
                                       hint={'Browse local backup directory.'}>Browse</FormButton>
            repoPlaceHolder = 'Enter or browse the local path of the repo home dir used by Borg.';
        }
        return <React.Fragment>
            <PageHeader>
                Configure repository
            </PageHeader>
            <form>
                <FormGroup>
                    <FormLabel length={2}>{'Mode'}</FormLabel>
                    <FormField length={10}>
                        <FormRadioButton name={'mode'} id={'mode1'} label={'Add existing repository'}
                                         value={'existingRepo'}
                                         checked={this.state.mode === 'existingRepo'}
                                         onChange={this.handleCheckboxChange}
                                         hint={'Do you want to add an already existing Borg repository?'}/>
                        <FormRadioButton name={'mode'} id={'mode2'} label={'Init new repository'} value={'initNewRepo'}
                                         checked={this.state.mode === 'initNewRepo'}
                                         onChange={this.handleCheckboxChange}
                                         hint={'Do you want to create and init a new one?'}/>
                    </FormField>
                </FormGroup>
                <FormGroup>
                    <FormLabel length={2}>{'Local or remote'}</FormLabel>
                    <FormField length={10}>
                        <FormRadioButton name={'localRemote'} id={'localRemote1'} label={'Local repository'}
                                         value={'local'}
                                         checked={this.state.localRemote === 'local'}
                                         onChange={this.handleCheckboxChange}/>
                        <FormRadioButton name={'localRemote'} id={'localRemote2'} label={'Remote repository'}
                                         value={'remote'}
                                         checked={this.state.localRemote === 'remote'}
                                         onChange={this.handleCheckboxChange}/>
                    </FormField>
                </FormGroup>
                <FormLabelInputField label={'Display name'} fieldLength={12}
                                     name={'displayName'} value={repoConfig.displayName}
                                     onChange={this.handleRepoTextChange}
                                     placeholder="Enter display name (only for displaying purposes)."/>
                <FormGroup>
                    <FormLabel length={2}>{'Repo'}</FormLabel>
                    <FormField length={repoFieldLength}>
                        <FormInput
                            id={'repo'}
                            name={'repo'}
                            type={'text'}
                            value={repoConfig.repo}
                            onChange={this.handleRepoTextChange}
                            placeholder={repoPlaceHolder}
                        />
                    </FormField>
                    {browseButton}
                </FormGroup>
                <FormLabelInputField label={'RSH'} fieldLength={12}
                                     name={'rsh'} value={repoConfig.rsh}
                                     onChange={this.handleRepoTextChange}
                                     placeholder="Enter the rsh value (ssh command) for remote repository."
                                     className={this.state.localRemote === 'local' ? 'hidden' : null}/>
                <FormGroup className={this.state.mode === 'existingRepo' ? 'hidden' : null}>
                    <FormLabel length={2}>{'Encryption'}</FormLabel>
                    <FormField length={2}>
                        <FormSelect
                            value={repoConfig.encryption}
                            name={'encryption'}
                            onChange={this.handleRepoTextChange}
                            hint={'Encryption for the new repository (use repokey if you don\'t know what to choose).'}
                        >
                            <FormOption label={'repokey (SHA256)'} value={'repokey'}/>
                            <FormOption label={'repokey-blake2'} value={'repokey-blake2'}/>
                            <FormOption label={'keyfile (SHA256)'} value={'keyfile'}/>
                            <FormOption label={'none (not recommended)'} value={'none'}/>
                        </FormSelect>
                    </FormField>
                </FormGroup>
                <FormGroup>
                    <FormLabel length={2}>{'Password method'}</FormLabel>
                    <FormField length={3}>
                        <FormSelect
                            value={this.state.passwordMethod}
                            name={'passwordMethod'}
                            onChange={this.handleTextChange}
                        >
                            {passwordMethods
                                .map((entry) => <FormOption label={entry[1]} value={entry[0]}
                                                            key={entry[0]}/>)}
                        </FormSelect>
                    </FormField>
                </FormGroup>
                <FormGroup className={!this.state.passwordCreate ? 'hidden' : null}>
                    <FormLabel length={2}>{'Passphrase creation info'}</FormLabel>
                    <FormField length={10}>
                        {this.state.passwordCreate}
                    </FormField>
                </FormGroup>
                <FormLabelInputField label={'Password command'} fieldLength={12}
                                     name={'passwordCommand'} value={repoConfig.passwordCommand}
                                     onChange={this.handleRepoTextChange}
                                     placeholder="Enter the password command to get the command from or choose a method above."
                                     className={this.state.passwordMethod === 'passphrase' ? 'hidden' : null}
                />
                <FormLabelInputField label={'Password'} fieldLength={6} type={'password'}
                                     name={'passphrase'} value={repoConfig.passphrase}
                                     onChange={this.handleRepoTextChange}
                                     hint={"It's recommended to use password command instead."}
                                     className={this.state.passwordMethod !== 'passphrase' ? 'hidden' : null}
                />
                <FormField length={12}>
                    <Link to={'/repos'} className={'btn btn-outline-primary'}><I18n name={'common.cancel'}/>
                    </Link>
                    <FormButton onClick={this.onSave} bsStyle="primary"
                                hintKey="configuration.save.hint"><I18n name={'common.save'}/>
                    </FormButton>
                </FormField>
            </form>
            <code>
                <h2>Todo:</h2>
                <ul>
                    <li>Implement 'Save' button ;-)</li>
                    <li>Add own environment variables.</li>
                    <li>Implement browse button for local repos.</li>
                    <li>Note (for new backups): Save your password, otherwise your backup will be lost without!</li>
                    <li>Note (hide password fields): Your backup will not be encrypted!</li>
                    <li>Remove and clone repo.</li>
                </ul>
            </code>
        </React.Fragment>;
    }
}
export default ConfigureRepoPage;
borgbutler-webapp/src/components/views/repos/RepoArchiveListView.jsx
@@ -1,19 +1,22 @@
import React from 'react'
import {Nav, NavLink, TabContent, Table, TabPane} from 'reactstrap';
import { Link } from "react-router-dom";
import {Badge, Nav, NavLink, TabContent, Table, TabPane} from 'reactstrap';
import {Link} from "react-router-dom";
import classNames from 'classnames';
import {PageHeader} from '../../general/BootstrapComponents';
import {getRestServiceUrl, humanFileSize} from '../../../utilities/global';
import ErrorAlert from '../../general/ErrorAlert';
import {IconCheck, IconRefresh} from '../../general/IconComponents';
import JobMonitorPanel from '../jobs/JobMonitorPanel';
import RepoConfigPanel from "./RepoConfigPanel";
class RepoArchiveListView extends React.Component {
    state = {
        id: this.props.match.params.id,
        displayName: this.props.match.params.displayName,
        isFetching: false,
        activeTab: '1',
        redirectOnError: true
    };
    componentDidMount = () => {
@@ -42,7 +45,12 @@
                    repo: json
                })
            })
            .catch(() => this.setState({isFetching: false, failed: true}));
            .catch(() => {
                this.setState({isFetching: false, failed: true})
                if (this.state.redirectOnError && this.state.activeTab !== '3') {
                    this.setState({activeTab: '3', redirectOnError: false});
                }
            });
    };
    toggleTab = tab => () => {
@@ -51,22 +59,46 @@
        })
    };
    afterSave() {
        if (!this.state.failed) {
            this.setState({
                activeTab: '1'
            })
        }
    }
    afterCancel() {
        if (!this.state.failed) {
            this.setState({
                activeTab: '1'
            })
        }
    }
    render = () => {
        let content = undefined;
        let errorBadge = '';
        let content1 = undefined;
        let content2 = undefined;
        const repo = this.state.repo;
        let pageHeader = '';
        const displayName = (this.state.displayName) ? this.state.displayName : `Error: id=${this.state.id}`;
        let pageHeader = <React.Fragment>
            {displayName}
        </React.Fragment>;
        if (this.state.isFetching) {
            content = <JobMonitorPanel repo={this.state.id} />;
            content1 = <JobMonitorPanel repo={this.state.id}/>;
            content2 = content1;
        } else if (this.state.failed) {
            content = <ErrorAlert
            content1 = <ErrorAlert
                title={'Cannot load Repositories'}
                description={'Something went wrong during contacting the rest api.'}
                description={'Something went wrong, may-be wrong configuration?'}
                action={{
                    handleClick: this.fetchRepo,
                    title: 'Try again'
                }}
            />;
            content2 = content1;
            errorBadge = <Badge color="danger" pill>!</Badge>;
        } else if (this.state.repo) {
            pageHeader = <React.Fragment>
                {repo.displayName}
@@ -127,82 +159,92 @@
                    <td>{repo.cache.path}</td>
                </tr>
            }
            content = <React.Fragment>
                <Nav tabs>
                    <NavLink
                        className={classNames({active: this.state.activeTab === '1'})}
                        onClick={this.toggleTab('1')}
                    >
                        Archives
                    </NavLink>
                    <NavLink
                        className={classNames({active: this.state.activeTab === '2'})}
                        onClick={this.toggleTab('2')}
                    >
                        Information
                    </NavLink>
                </Nav>
                <TabContent activeTab={this.state.activeTab}>
                    <TabPane tabId={'1'}>
                        <Table hover>
                            <tbody>
                            <tr>
                                <th>Archive</th>
                                <th>Time</th>
                                <th></th>
                                <th>Id</th>
                            </tr>
                            {repo.archives.map((archive) => {
                                // Return the element. Also pass key
                                let loaded = '';
                                if (archive.fileListAlreadyCached) {
                                    loaded = <IconCheck />;
                                }
                                return (
                                    <tr key={archive.id}>
                                        <td><Link to={`/archives/${repo.id}/${archive.id}/`}>{archive.name}</Link></td>
                                        <td>{archive.time}</td>
                                        <td>{loaded}</td>
                                        <td>{archive.id}</td>
                                    </tr>);
                            })}
                            </tbody>
                        </Table>
                    </TabPane>
                    <TabPane tabId={'2'}>
                        <Table striped bordered hover>
                            <tbody>
                            <tr>
                                <td>Id</td>
                                <td>{repo.id}</td>
                            </tr>
                            <tr>
                                <td>Name</td>
                                <td>{repo.name}</td>
                            </tr>
                            <tr>
                                <td>Location</td>
                                <td>{repo.location}</td>
                            </tr>
                            {stats}
                            <tr>
                                <td>Security dir</td>
                                <td>{repo.securityDir}</td>
                            </tr>
                            {encryption}
                            {cachePath}
                            </tbody>
                        </Table>
                    </TabPane>
                </TabContent>
            </React.Fragment>;
            content1 = <Table hover>
                <tbody>
                <tr>
                    <th>Archive</th>
                    <th>Time</th>
                    <th></th>
                    <th>Id</th>
                </tr>
                {repo.archives.map((archive) => {
                    // Return the element. Also pass key
                    let loaded = '';
                    if (archive.fileListAlreadyCached) {
                        loaded = <IconCheck/>;
                    }
                    return (
                        <tr key={archive.id}>
                            <td><Link to={`/archives/${repo.id}/${archive.id}/`}>{archive.name}</Link></td>
                            <td>{archive.time}</td>
                            <td>{loaded}</td>
                            <td>{archive.id}</td>
                        </tr>);
                })}
                </tbody>
            </Table>;
            content2 = <Table striped bordered hover>
                <tbody>
                <tr>
                    <td>Id</td>
                    <td>{repo.id}</td>
                </tr>
                <tr>
                    <td>Name</td>
                    <td>{repo.name}</td>
                </tr>
                <tr>
                    <td>Location</td>
                    <td>{repo.location}</td>
                </tr>
                {stats}
                <tr>
                    <td>Security dir</td>
                    <td>{repo.securityDir}</td>
                </tr>
                {encryption}
                {cachePath}
                </tbody>
            </Table>;
        }
        return <React.Fragment>
            <PageHeader>
                {pageHeader}
                <Link to={'/repos'}> Repositories</Link> - {pageHeader}
            </PageHeader>
            {content}
            <Nav tabs>
                <NavLink
                    className={classNames({active: this.state.activeTab === '1'})}
                    onClick={this.toggleTab('1')}
                >
                    Archives {errorBadge}
                </NavLink>
                <NavLink
                    className={classNames({active: this.state.activeTab === '2'})}
                    onClick={this.toggleTab('2')}
                >
                    Information {errorBadge}
                </NavLink>
                <NavLink
                    className={classNames({active: this.state.activeTab === '3'})}
                    onClick={this.toggleTab('3')}
                >
                    Configuration
                </NavLink>
            </Nav>
            <TabContent activeTab={this.state.activeTab}>
                <TabPane tabId={'1'}>
                    {content1}
                </TabPane>
                <TabPane tabId={'2'}>
                    {content2}
                </TabPane>
                <TabPane tabId={'3'}>
                    <RepoConfigPanel id={this.state.id} afterCancel={this.afterCancel} afterSave={this.afterSave}
                                     repoError={this.state.failed}/>
                </TabPane>
            </TabContent>
        </React.Fragment>;
    };
@@ -211,6 +253,8 @@
        this.fetchRepo = this.fetchRepo.bind(this);
        this.toggleTab = this.toggleTab.bind(this);
        this.afterCancel = this.afterCancel.bind(this);
        this.afterSave = this.afterSave.bind(this);
    }
}
borgbutler-webapp/src/components/views/repos/RepoCard.jsx
@@ -18,7 +18,7 @@
        let repoText = this.buildItem(null, content);
        return <React.Fragment>
            <Card tag={Link} to={`/repoArchives/${repo.id}`} outline color="success" className={'repo'}
            <Card tag={Link} to={`/repoArchives/${repo.id}/${repo.displayName}`} outline color="success" className={'repo'}
                  style={{backgroundColor: '#fff'}}>
                <CardHeader>{repo.displayName}</CardHeader>
                <CardBody>
borgbutler-webapp/src/components/views/repos/RepoConfigPanel.jsx
New file
@@ -0,0 +1,153 @@
import React from 'react';
import {FormGroup} from 'reactstrap';
import {FormButton, FormField, FormLabelInputField} from '../../general/forms/FormComponents';
import {getRestServiceUrl} from '../../../utilities/global';
import I18n from "../../general/translation/I18n";
import LoadingOverlay from '../../general/loading/LoadingOverlay';
import PropTypes from "prop-types";
import ErrorAlert from "../../general/ErrorAlert";
class RepoConfigPanel extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            loading: false,
            repoConfig: undefined
        };
        this.fetch = this.fetch.bind(this);
        this.handleTextChange = this.handleTextChange.bind(this);
        this.onSave = this.onSave.bind(this);
        this.onCancel = this.onCancel.bind(this);
    }
    componentDidMount = () => this.fetch();
    fetch = () => {
        this.setState({
            isFetching: true,
            failed: false,
            repoConfig: undefined
        });
        fetch(getRestServiceUrl('repoConfig', {
            id: this.props.id
        }), {
            method: 'GET',
            headers: {
                'Accept': 'application/json'
            }
        })
            .then(response => response.json())
            .then(json => {
                this.setState({
                    isFetching: false,
                    repoConfig: json
                })
            })
            .catch((error) => {
                console.log("error", error);
                this.setState({
                    isFetching: false,
                    failed: true
                });
            })
    };
    handleTextChange = event => {
        event.preventDefault();
        this.setState({repoConfig: {...this.state.repoConfig, [event.target.name]: event.target.value}});
    }
    async onSave(event) {
        const response = fetch(getRestServiceUrl("repoConfig"), {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(this.state.repoConfig)
        });
        if (response) await response;
        if (this.props.afterSave) {
            this.props.afterSave();
        }
    }
    async onCancel() {
        const response = this.fetch();
        if (response) await response;
        if (this.props.afterCancel) {
            this.props.afterCancel();
        }
    }
    render() {
        let content;
        let repoError = '';
        if (this.props.repoError) {
            repoError =<ErrorAlert
                title={'Cannot access repository'}
                description={'Repo not available or mis-configured (please refer the log files for more details).'}
            />
        }
        if (this.state.isFetching) {
            content = <React.Fragment>Loading...</React.Fragment>;
        } else if (this.state.failed) {
            content = <ErrorAlert
                title={'Cannot load config of repository'}
                description={'Something went wrong during contacting the rest api.'}
                action={{
                    handleClick: this.fetchRepo,
                    title: 'Try again'
                }}
            />;
        } else if (this.state.repoConfig) {
            const repoConfig = this.state.repoConfig;
            content = <React.Fragment>
                <FormGroup>
                    <FormLabelInputField label={'Display name'} fieldLength={12}
                                         name={'displayName'} value={repoConfig.displayName}
                                         onChange={this.handleTextChange}
                                         placeholder="Enter display name (only for displaying purposes)."/>
                    <FormLabelInputField label={'Repo'} fieldLength={12}
                                         name={'repo'} value={repoConfig.repo}
                                         onChange={this.handleTextChange}
                                         placeholder="Enter the name of the repo, used by Borg."/>
                    <FormLabelInputField label={'RSH'} fieldLength={12}
                                         name={'rsh'} value={repoConfig.rsh}
                                         onChange={this.handleTextChange}
                                         placeholder="Enter the rsh value (ssh command) for remote repository."/>
                    <FormLabelInputField label={'Password command'} fieldLength={12}
                                         name={'passwordCommand'} value={repoConfig.passwordCommand}
                                         onChange={this.handleTextChange}
                                         placeholder="Enter the password command to get the command from."/>
                    <FormLabelInputField label={'Password'} fieldLength={6} type={'password'}
                                         name={'passphrase'} value={repoConfig.passphrase}
                                         onChange={this.handleTextChange}
                                         hint={"It's recommended to use password command instead."}
                    />
                    <FormField length={12}>
                        <FormButton onClick={this.onCancel}
                                    hintKey="configuration.cancel.hint"><I18n name={'common.cancel'}/>
                        </FormButton>
                        <FormButton onClick={this.onSave} bsStyle="primary"
                                    hintKey="configuration.save.hint"><I18n name={'common.save'}/>
                        </FormButton>
                    </FormField>
                </FormGroup>
                <LoadingOverlay active={this.state.loading}/>
            </React.Fragment>;
        }
        return <React.Fragment>{content}{repoError}</React.Fragment>;
    }
}
RepoConfigPanel.propTypes = {
    afterCancel: PropTypes.func.isRequired,
    afterSave: PropTypes.func.isRequired,
    id: PropTypes.string,
    repoError: PropTypes.bool
};
export default RepoConfigPanel;
borgbutler-webapp/src/components/views/repos/RepoListView.jsx
@@ -1,11 +1,12 @@
import React from 'react'
import {Link} from 'react-router-dom'
import './RepoListView.css';
import {CardDeck} from 'reactstrap';
import {PageHeader} from '../../general/BootstrapComponents';
import {getRestServiceUrl} from '../../../utilities/global';
import ErrorAlert from '../../general/ErrorAlert';
import RepoCard from './RepoCard';
import {IconRefresh} from "../../general/IconComponents";
import {IconAdd, IconRefresh} from "../../general/IconComponents";
class RepoListView extends React.Component {
@@ -73,12 +74,12 @@
            content = <React.Fragment>
                <CardDeck>
                {this.state.repos.map(repo => {
                    return <RepoCard
                        key={repo.id}
                        repo={repo}
                    />;
                })}
                    {this.state.repos.map(repo => {
                        return <RepoCard
                            key={repo.id}
                            repo={repo}
                        />;
                    })}
                </CardDeck>
            </React.Fragment>;
@@ -95,6 +96,12 @@
                </div>
            </PageHeader>
            {content}
            <br/>
            <Link to={'/repo/configure'}
                  className={'btn btn-outline-primary'}
            >
                <IconAdd/>
            </Link>
        </React.Fragment>;
    };
borgbutler-webapp/src/containers/WebApp.jsx
@@ -17,6 +17,7 @@
import Footer from '../components/views/footer/Footer';
import {loadVersion} from '../actions';
import {getTranslation} from '../utilities/i18n';
import ConfigureRepoPage from '../components/views/repos/ConfigureRepoPage';
class WebApp extends React.Component {
@@ -24,11 +25,11 @@
    componentDidMount = () => {
        this.props.loadVersion();
        this.interval = setInterval(() => this.fetchJobStatistics(), 5000);
        this.interval = setInterval(() => this.fetchSystemInfo(), 5000);
    };
    fetchJobStatistics = () => {
        fetch(getRestServiceUrl('jobs/statistics'), {
    fetchSystemInfo = () => {
        fetch(getRestServiceUrl('system/info'), {
            method: 'GET',
            headers: {
                'Accept': 'application/json'
@@ -37,7 +38,7 @@
            .then(response => response.json())
            .then(json => {
                this.setState({
                    statistics: json
                    systemInfo: json
                });
            })
            .catch();
@@ -45,15 +46,20 @@
    render() {
        let jobsBadge = '';
        if (this.state && this.state.statistics && this.state.statistics.numberOfRunningAndQueuedJobs > 0) {
            jobsBadge = <Badge color="danger" pill>{this.state.statistics.numberOfRunningAndQueuedJobs}</Badge>;
        const statistics = (this.state && this.state.systemInfo) ? this.state.systemInfo.queueStatistics : null;
        if (statistics && statistics.numberOfRunningAndQueuedJobs > 0) {
            jobsBadge = <Badge color="danger" pill>{statistics.numberOfRunningAndQueuedJobs}</Badge>;
        }
        let configurationBadge = '';
        if (this.state && this.state.systemInfo && !this.state.systemInfo.configurationOK) {
            configurationBadge = <Badge color="danger" pill>!</Badge>;
        }
        let routes = [
            ['Start', '/', Start],
            ['Repositories', '/repos', RepoListView],
            ['Job monitor', '/jobmonitor', JobMonitorView, {badge: jobsBadge}],
            [getTranslation('logviewer'), '/logging', LogPage],
            [getTranslation('configuration'), '/config', ConfigurationPage]
            [getTranslation('configuration'), '/config', ConfigurationPage, {badge: configurationBadge}]
        ];
        if (isDevelopmentMode()) {
@@ -76,8 +82,9 @@
                                    />
                                ))
                            }
                            <Route path={'/repoArchives/:id'} component={RepoArchiveListView}/>
                            <Route path={'/repoArchives/:id/:displayName'} component={RepoArchiveListView} />
                            <Route path={'/archives/:repoId/:archiveId/'} component={ArchiveView} />
                            <Route path={'/repo/configure'} component={ConfigureRepoPage} />
                        </Switch>
                    </div>
                    <Footer versionInfo={this.props.version}/>
borgbutler-webapp/src/css/my-style.css
@@ -2,6 +2,19 @@
    color: #50555f;
}
.hidden {
    display: none;
}
.command-line {
    font-family: monospace;
    white-space: pre;
    border-style: solid;
    background-color: black;
    color: white;
    padding-left: 5pt;
}
a:hover {
    color: #47a7eb;
}
@@ -175,6 +188,10 @@
    color: #47a7eb;
}
ul.nav-tabs a.active {
    font-weight: bold;
}
.tab-pane.active {
    padding: 20px;
}