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) {