diff --git a/htmlui/src/App.css b/htmlui/src/App.css
index 532da3926..fc14a7f7c 100644
--- a/htmlui/src/App.css
+++ b/htmlui/src/App.css
@@ -32,4 +32,21 @@
.hidden {
display: none;
+}
+
+div.tab-body {
+ padding: 10px;
+ border-left: 1px solid #ccc;
+ border-right: 1px solid #ccc;
+ border-bottom: 1px solid #ccc;
+}
+
+.policy-help {
+ font-size: 80%;
+ font-weight: normal;
+}
+
+.new-policy-panel {
+ background-color: #f0f0f0;
+ padding: 10px;
}
\ No newline at end of file
diff --git a/htmlui/src/PoliciesTable.js b/htmlui/src/PoliciesTable.js
index 07ba9f7d3..589f6a9bb 100644
--- a/htmlui/src/PoliciesTable.js
+++ b/htmlui/src/PoliciesTable.js
@@ -1,7 +1,11 @@
import axios from 'axios';
import React, { Component } from 'react';
+import Badge from 'react-bootstrap/Badge';
+import Button from 'react-bootstrap/Button';
+import Form from 'react-bootstrap/Form';
+import { handleChange, OptionalField, RequiredField, validateRequiredFields } from './forms';
+import { PolicyEditor } from './PolicyEditor';
import MyTable from './Table';
-import { intervalDisplayName, sourceDisplayName, timesOfDayDisplayName } from './uiutil';
export class PoliciesTable extends Component {
constructor() {
@@ -10,9 +14,21 @@ export class PoliciesTable extends Component {
items: [],
isLoading: false,
error: null,
+ editorTarget: null,
+ selectedRowIsNew: false,
};
+
+ this.editorClosed = this.editorClosed.bind(this);
+ this.setNewPolicy = this.setNewPolicy.bind(this);
+ this.handleChange = handleChange.bind(this);
+ this.fetchPolicies = this.fetchPolicies.bind(this);
}
+
componentDidMount() {
+ this.fetchPolicies();
+ }
+
+ fetchPolicies() {
axios.get('/api/v1/policies').then(result => {
this.setState({ "items": result.data.policies });
}).catch(error => this.setState({
@@ -20,6 +36,72 @@ export class PoliciesTable extends Component {
isLoading: false
}));
}
+
+ editorClosed() {
+ this.setState({
+ editorTarget: null,
+ });
+ this.fetchPolicies();
+ }
+
+ setNewPolicy() {
+ if (!validateRequiredFields(this, ["targetHost"])) {
+ return;
+ }
+
+ function fixInput(a) {
+ if (!a) {
+ return "";
+ }
+
+ // allow both * and empty string to indicate "all"
+ if (a === "*") {
+ return ""
+ }
+
+ return a;
+ }
+
+ this.setState({
+ editorTarget: {
+ userName: fixInput(this.state.targetUsername),
+ host: this.state.targetHost,
+ path: fixInput(this.state.targetPath),
+ },
+ selectedRowIsNew: true,
+ })
+ }
+
+ policySummary(p) {
+ function isEmpty(obj) {
+ for(var key in obj) {
+ if(obj.hasOwnProperty(key))
+ return false;
+ }
+
+ return true;
+ }
+
+ let bits = [];
+ if (!isEmpty(p.policy.retention)) {
+ bits.push(<>retention{' '}>);
+ }
+ if (!isEmpty(p.policy.files)) {
+ bits.push(<>files{' '}>);
+ }
+ if (!isEmpty(p.policy.errorHandling)) {
+ bits.push(<>errors{' '}>);
+ }
+ if (!isEmpty(p.policy.compression)) {
+ bits.push(<>compression{' '}>);
+ }
+ if (!isEmpty(p.policy.scheduling)) {
+ bits.push(<>scheduling{' '}>);
+ }
+
+ return bits;
+ }
+
render() {
const { items, isLoading, error } = this.state;
if (error) {
@@ -29,39 +111,48 @@ export class PoliciesTable extends Component {
return
Loading ...
;
}
const columns = [{
- id: 'target',
- Header: 'Target',
- accessor: x => sourceDisplayName(x.target),
+ Header: 'Username',
+ accessor: x => x.target.userName || "*",
}, {
- Header: 'Latest',
- accessor: 'policy.retention.keepLatest'
+ Header: 'Host',
+ accessor: x => x.target.host || "*",
}, {
- Header: 'Hourly',
- accessor: 'policy.retention.keepHourly'
+ Header: 'Path',
+ accessor: x => x.target.path || "*",
}, {
- Header: 'Daily',
- accessor: 'policy.retention.keepDaily'
+ Header: 'Defined',
+ width: 300,
+ accessor: x => this.policySummary(x),
}, {
- Header: 'Weekly',
- accessor: 'policy.retention.keepWeekly'
- }, {
- Header: 'Monthly',
- accessor: 'policy.retention.keepMonthly'
- }, {
- Header: 'Annual',
- accessor: 'policy.retention.keepAnnual'
- }, {
- id: 'interval',
- Header: 'Interval',
- accessor: x => intervalDisplayName(x.policy.scheduling.interval),
- }, {
- id: 'timesOfDay',
- Header: 'Times of Day',
- accessor: x => timesOfDayDisplayName(x.policy.scheduling.timesOfDay),
+ id: 'edit',
+ Header: '',
+ width: 50,
+ Cell: x => ,
}]
return
-
+
+
+
+
+
+
+
+ {OptionalField(this, "Target Username", "targetUsername", {}, "Specify * all empty to target all users")}
+ {RequiredField(this, "Target Host", "targetHost", {})}
+ {OptionalField(this, "Target Path", "targetPath", {}, "Specify * all empty to target all filesystem paths")}
+
+
+
+
+
+ {this.state.editorTarget &&
}
;
}
}
diff --git a/htmlui/src/PolicyEditor.js b/htmlui/src/PolicyEditor.js
new file mode 100644
index 000000000..5ca5b8be8
--- /dev/null
+++ b/htmlui/src/PolicyEditor.js
@@ -0,0 +1,241 @@
+import axios from 'axios';
+import React, { Component } from 'react';
+import Button from 'react-bootstrap/Button';
+import Col from 'react-bootstrap/Col';
+import Form from 'react-bootstrap/Form';
+import Tab from 'react-bootstrap/Tab';
+import Tabs from 'react-bootstrap/Tabs';
+import { handleChange, OptionalBoolean, OptionalNumberField, RequiredBoolean, stateProperty, StringList } from './forms';
+
+function sourceDisplayName(s) {
+ if (!s.host && !s.userName) {
+ return "global"
+ }
+
+ if (!s.userName) {
+ return "host " + s.host;
+ }
+
+ if (!s.path) {
+ return "user " + s.userName + "@" + s.host;
+ }
+
+ return "user " + s.userName + "@" + s.host + " path '" + s.path + "'";
+}
+
+export class PolicyEditor extends Component {
+ constructor() {
+ super();
+ this.state = {
+ items: [],
+ isLoading: false,
+ error: null,
+ };
+
+ this.fetchPolicy = this.fetchPolicy.bind(this);
+ this.handleChange = handleChange.bind(this);
+ this.saveChanges = this.saveChanges.bind(this);
+ this.isGlobal = this.isGlobal.bind(this);
+ this.deletePolicy = this.deletePolicy.bind(this);
+ this.snapshotURL = this.snapshotURL.bind(this);
+ }
+
+ componentDidMount() {
+ axios.get('/api/v1/repo/algorithms').then(result => {
+ this.setState({
+ algorithms: result.data,
+ });
+
+ this.fetchPolicy(this.props);
+ });
+ }
+
+ componentWillReceiveProps(props) {
+ this.fetchPolicy(props);
+ }
+
+ fetchPolicy(props) {
+ if (props.isNew) {
+ this.setState({
+ isLoading: false,
+ policy: {},
+ });
+
+ return;
+ }
+
+ axios.get(this.snapshotURL(props)).then(result => {
+ this.setState({
+ isLoading: false,
+ policy: result.data,
+ });
+ }).catch(error => this.setState({
+ error: error,
+ isLoading: false
+ }));
+ }
+
+ saveChanges() {
+ function removeEmpty(l) {
+ if (!l) {
+ return l;
+ }
+
+ let result = [];
+ for (let i = 0; i < l.length; i++) {
+ const s = l[i];
+ if (s === "") {
+ continue;
+ }
+
+ result.push(s);
+ }
+
+ return result;
+ }
+
+ // clean up policy before saving
+ let policy = JSON.parse(JSON.stringify(this.state.policy));
+ if (policy.files) {
+ if (policy.files.ignore) {
+ policy.files.ignore = removeEmpty(policy.files.ignore)
+ }
+ if (policy.files.ignoreDotFiles) {
+ policy.files.ignoreDotFiles = removeEmpty(policy.files.ignoreDotFiles)
+ }
+ }
+
+ if (policy.compression) {
+ if (policy.compression.onlyCompress) {
+ policy.compression.onlyCompress = removeEmpty(policy.compression.onlyCompress)
+ }
+ if (policy.compression.neverCompress) {
+ policy.compression.neverCompress = removeEmpty(policy.compression.neverCompress)
+ }
+ }
+
+ axios.put(this.snapshotURL(this.props), policy).then(result => {
+ this.props.close();
+ }).catch(error => {
+ alert('Error saving snapshot: ' + error);
+ });
+ }
+
+ deletePolicy() {
+ if (window.confirm('Are you sure you want to delete this policy?')) {
+ axios.delete(this.snapshotURL(this.props)).then(result => {
+ this.props.close();
+ }).catch(error => {
+ alert('Delete error: ' + error);
+ });
+ }
+ }
+
+ snapshotURL(props) {
+ return '/api/v1/policy?host=' + props.host + '&userName=' + props.userName + '&path=' + props.path;
+ }
+
+ isGlobal() {
+ return !this.props.host && !this.props.userName && !this.props.path;
+ }
+
+ render() {
+ const { isLoading, error } = this.state;
+ if (error) {
+ return {error.message}
;
+ }
+
+ if (isLoading) {
+ return Loading ...
;
+ }
+
+ return
+
{this.props.isNew && "New "}Policy: {sourceDisplayName(this.props)}
+
+
+
+
+
Controls how many latest snapshots to keep per source directory
+
+ {OptionalNumberField(this, "Latest", "policy.retention.keepLatest", { placeholder: "# of latest snapshots" })}
+ {OptionalNumberField(this, "Hourly", "policy.retention.keepHourly", { placeholder: "# of hourly snapshots" })}
+ {OptionalNumberField(this, "Daily", "policy.retention.keepDaily", { placeholder: "# of daily snapshots" })}
+
+
+ {OptionalNumberField(this, "Weekly", "policy.retention.keepWeekly", { placeholder: "# of weekly snapshots" })}
+ {OptionalNumberField(this, "Monthly", "policy.retention.keepMonthly", { placeholder: "# of monthly snapshots" })}
+ {OptionalNumberField(this, "Annual", "policy.retention.keepAnnual", { placeholder: "# of annual snapshots" })}
+
+
+
+
+
+
Controls which files should be included and excluded when snapshotting. Use .gitignore syntax.
+
+ {StringList(this, "Ignore Rules", "policy.files.ignore", "List of file name patterns to ignore.")}
+ {StringList(this, "Ignore Rule Files", "policy.files.ignoreDotFiles", "List of additional files containing ignore rules. Each file configures ignore rules for the directory and its subdirectories.")}
+
+
+ {RequiredBoolean(this, "Ignore Parent Rules", "policy.files.noParentIgnore")}
+ {RequiredBoolean(this, "Ignore Parent Rule Files", "policy.files.noParentDotFiles")}
+
+
+
+
+
+
Controls how errors detected while snapshotting are handled.
+
+ {OptionalBoolean(this, "Ignore Directory Errors", "policy.errorHandling.ignoreDirectoryErrors", "inherit from parent")}
+ {OptionalBoolean(this, "Ignore File Errors", "policy.errorHandling.ignoreFileErrors", "inherit from parent")}
+
+
+
+
+
+
Controls which files are compressed.
+
+
+ Compression
+
+
+ {this.state.algorithms && this.state.algorithms.compression.map(x => )}
+
+
+ {OptionalNumberField(this, "Min File Size", "policy.compression.minSize", { placeholder: "minimum file size to compress" })}
+ {OptionalNumberField(this, "Max File Size", "policy.compression.maxSize", { placeholder: "maximum file size to compress" })}
+
+
+ {StringList(this, "Only Compress Extensions", "policy.compression.onlyCompress", "Only compress files with the above file extensions (one extension per line)")}
+ {StringList(this, "Never Compress Extensions", "policy.compression.neverCompress", "Never compress the above file extensions (one extension per line)")}
+
+
+
+
+
+
Controls when snapshots are automatically created.
+
+ {OptionalNumberField(this, "Snapshot Interval", "policy.scheduling.intervalSeconds", { placeholder: "seconds" })}
+
+
+
+
+
+ {RequiredBoolean(this, "Disable Parent Policy Evaluation (prevents any parent policies from affecting this directory and subdirectories)", "policy.noParent")}
+
+
+
+ {!this.props.isNew && <>
+
+ >}
+
+
+
+
JSON representation
+
{JSON.stringify(this.state.policy, null, 4)}
+
+
;
+ }
+}
diff --git a/htmlui/src/forms.js b/htmlui/src/forms.js
index 270d0e2c1..08291985a 100644
--- a/htmlui/src/forms.js
+++ b/htmlui/src/forms.js
@@ -24,32 +24,181 @@ export function validateRequiredFields(component, fields) {
return true;
}
-export function handleChange(event) {
- this.setState({
- [event.target.name]: event.target.value,
- });
+export function handleChange(event, valueGetter=x=>x.value) {
+ let newState = { ...this.state };
+ let st = newState;
+
+ const parts = event.target.name.split(/\./);
+
+ for (let i = 0; i < parts.length - 1; i++) {
+ const part = parts[i];
+
+ if (st[part] === undefined) {
+ st[part] = {}
+ }
+
+ st = st[part]
+ }
+
+ const part = parts[parts.length - 1]
+ const v = valueGetter(event.target);
+ st[part] = v;
+
+ this.setState(newState);
}
-export function RequiredField(component, label, name, props={}) {
+export function stateProperty(component, name) {
+ let st = component.state;
+ const parts = name.split(/\./);
+
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ if (st === undefined) {
+ return undefined;
+ }
+
+ st = st[part];
+ }
+
+ return st;
+}
+
+export function RequiredField(component, label, name, props = {}, helpText = null) {
return
{label}
+ {helpText && {helpText}}
{label} Is Required
}
-export function OptionalField(component, label, name, props={}) {
+export function OptionalField(component, label, name, props = {}, helpText = null) {
return
{label}
+ {helpText && {helpText}}
+
+}
+
+function valueToNumber(t) {
+ if (t.value === "") {
+ return undefined;
+ }
+
+ const v = Number.parseInt(t.value);
+ if (isNaN(v)) {
+ return t.value + '';
+ }
+
+ return v;
+}
+
+function isInvalidNumber(v) {
+ if (v === undefined) {
+ return false
+ }
+
+ if (isNaN(Number.parseInt(v))) {
+ return true;
+ }
+
+ return false;
+}
+
+export function OptionalNumberField(component, label, name, props = {}) {
+ return
+ {label}
+ component.handleChange(e, valueToNumber)}
+ {...props} />
+ Must be a valid number or empty
+
+}
+
+function checkedToBool(t) {
+ if (t.checked) {
+ return true;
+ }
+
+ return undefined;
+}
+
+export function RequiredBoolean(component, label, name, defaultLabel) {
+ return
+ component.handleChange(e, checkedToBool)}
+ type="checkbox" />
+
+}
+
+function optionalBooleanValue(target) {
+ if (target.value === "true") {
+ return true;
+ }
+ if (target.value === "false") {
+ return false;
+ }
+
+ return undefined;
+}
+
+export function OptionalBoolean(component, label, name, defaultLabel) {
+ return
+ {label}
+ component.handleChange(e, optionalBooleanValue)}
+ as="select">
+
+
+
+
+
+}
+
+function listToMultilineString(v) {
+ if (v) {
+ return v.join("\n");
+ }
+
+ return "";
+}
+
+function multilineStringToList(target) {
+ const v = target.value;
+ if (v === "") {
+ return undefined;
+ }
+
+ return v.split(/\n/);
+}
+
+export function StringList(component, label, name, helpText) {
+ return
+ {label}
+ component.handleChange(e, multilineStringToList)}
+ as="textarea"
+ rows="5">
+
+ {helpText}
}
\ No newline at end of file
diff --git a/htmlui/src/uiutil.js b/htmlui/src/uiutil.js
index e3dab1b74..0b01a4d5c 100644
--- a/htmlui/src/uiutil.js
+++ b/htmlui/src/uiutil.js
@@ -1,15 +1,3 @@
-export function sourceDisplayName(s) {
- if (!s.host && !s.userName) {
- return "(all)"
- }
-
- if (!s.userName) {
- return "(all)@" + s.host;
- }
-
- return s.userName + "@" + s.host + ":" + s.path;
-}
-
const base10UnitPrefixes = ["", "K", "M", "G", "T"];
function niceNumber(f) {