htmlui: added first version of UI policy editor

This commit is contained in:
Jarek Kowalski
2020-02-16 15:55:05 -08:00
parent c42b5cd89f
commit 03dad366e1
5 changed files with 536 additions and 50 deletions

View File

@@ -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;
}

View File

@@ -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(<><Badge variant="success">retention</Badge>{' '}</>);
}
if (!isEmpty(p.policy.files)) {
bits.push(<><Badge variant="primary">files</Badge>{' '}</>);
}
if (!isEmpty(p.policy.errorHandling)) {
bits.push(<><Badge variant="danger">errors</Badge>{' '}</>);
}
if (!isEmpty(p.policy.compression)) {
bits.push(<><Badge variant="secondary">compression</Badge>{' '}</>);
}
if (!isEmpty(p.policy.scheduling)) {
bits.push(<><Badge variant="warning">scheduling</Badge>{' '}</>);
}
return bits;
}
render() {
const { items, isLoading, error } = this.state;
if (error) {
@@ -29,39 +111,48 @@ export class PoliciesTable extends Component {
return <p>Loading ...</p>;
}
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 => <Button size="sm" onClick={() => {
this.setState({
editorTarget: x.row.original.target,
selectedRowIsNew: false,
})
}}>Edit</Button>,
}]
return <div className="padded">
<MyTable data={items} columns={columns} />
<div className={this.state.editorTarget ? "hidden" : "normal"}>
<MyTable data={items} columns={columns} />
</div>
<div className={this.state.editorTarget ? "hidden" : "normal"}>
<hr/>
<div className="new-policy-panel"><Form>
<Form.Row>
{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")}
</Form.Row>
<Button variant="primary" onClick={this.setNewPolicy}>Set New Policy</Button>
</Form>
</div>
</div>
{this.state.editorTarget && <PolicyEditor host={this.state.editorTarget.host} userName={this.state.editorTarget.userName} path={this.state.editorTarget.path} close={this.editorClosed} isNew={this.state.selectedRowIsNew} />}
</div>;
}
}

241
htmlui/src/PolicyEditor.js Normal file
View File

@@ -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 <p>{error.message}</p>;
}
if (isLoading) {
return <p>Loading ...</p>;
}
return <div className="padded">
<h3>{this.props.isNew && "New "}Policy: {sourceDisplayName(this.props)}</h3>
<Tabs defaultActiveKey="retention">
<Tab eventKey="retention" title="Retention">
<div className="tab-body">
<p className="policy-help">Controls how many latest snapshots to keep per source directory</p>
<Form.Row>
{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" })}
</Form.Row>
<Form.Row>
{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" })}
</Form.Row>
</div>
</Tab>
<Tab eventKey="files" title="Files">
<div className="tab-body">
<p className="policy-help">Controls which files should be included and excluded when snapshotting. Use <a href="https://git-scm.com/docs/gitignore">.gitignore</a> syntax.</p>
<Form.Row>
{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.")}
</Form.Row>
<Form.Row>
{RequiredBoolean(this, "Ignore Parent Rules", "policy.files.noParentIgnore")}
{RequiredBoolean(this, "Ignore Parent Rule Files", "policy.files.noParentDotFiles")}
</Form.Row>
</div>
</Tab>
<Tab eventKey="errors" title="Errors">
<div class="tab-body">
<p className="policy-help">Controls how errors detected while snapshotting are handled.</p>
<Form.Row>
{OptionalBoolean(this, "Ignore Directory Errors", "policy.errorHandling.ignoreDirectoryErrors", "inherit from parent")}
{OptionalBoolean(this, "Ignore File Errors", "policy.errorHandling.ignoreFileErrors", "inherit from parent")}
</Form.Row>
</div>
</Tab>
<Tab eventKey="compression" title="Compression">
<div class="tab-body">
<p className="policy-help">Controls which files are compressed.</p>
<Form.Row>
<Form.Group as={Col}>
<Form.Label className="required">Compression</Form.Label>
<Form.Control as="select"
name="policy.compression.compressorName"
onChange={this.handleChange}
value={stateProperty(this, "policy.compression.compressorName")}>
<option value="">(none)</option>
{this.state.algorithms && this.state.algorithms.compression.map(x => <option key={x} value={x}>{x}</option>)}
</Form.Control>
</Form.Group>
{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" })}
</Form.Row>
<Form.Row>
{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)")}
</Form.Row>
</div>
</Tab>
<Tab eventKey="scheduling" title="Scheduling">
<div class="tab-body">
<p className="policy-help">Controls when snapshots are automatically created.</p>
<Form.Row>
{OptionalNumberField(this, "Snapshot Interval", "policy.scheduling.intervalSeconds", { placeholder: "seconds" })}
</Form.Row>
</div>
</Tab>
</Tabs>
<Form.Row>
{RequiredBoolean(this, "Disable Parent Policy Evaluation (prevents any parent policies from affecting this directory and subdirectories)", "policy.noParent")}
</Form.Row>
<Button variant="success" onClick={this.saveChanges}>Save Policy</Button>
{!this.props.isNew && <>&nbsp;
<Button variant="danger" disabled={this.isGlobal()} onClick={this.deletePolicy}>Delete Policy</Button>
</>}
&nbsp;
<Button variant="dark" onClick={this.props.close}>Back To List</Button>
<hr />
<h5>JSON representation</h5>
<pre className="debug-json">{JSON.stringify(this.state.policy, null, 4)}
</pre>
</div>;
}
}

View File

@@ -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 <Form.Group as={Col}>
<Form.Label className="required">{label}</Form.Label>
<Form.Control
isInvalid={component.state[name] === ''}
isInvalid={stateProperty(component, name) === ''}
name={name}
value={component.state[name]}
onChange={component.handleChange}
value={stateProperty(component, name)}
onChange={component.handleChange}
{...props} />
{helpText && <Form.Text className="text-muted">{helpText}</Form.Text>}
<Form.Control.Feedback type="invalid">{label} Is Required</Form.Control.Feedback>
</Form.Group>
}
export function OptionalField(component, label, name, props={}) {
export function OptionalField(component, label, name, props = {}, helpText = null) {
return <Form.Group as={Col}>
<Form.Label>{label}</Form.Label>
<Form.Control
name={name}
value={component.state[name]}
onChange={component.handleChange}
value={stateProperty(component, name)}
onChange={component.handleChange}
{...props} />
{helpText && <Form.Text className="text-muted">{helpText}</Form.Text>}
</Form.Group>
}
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 <Form.Group as={Col}>
<Form.Label>{label}</Form.Label>
<Form.Control
name={name}
isInvalid={isInvalidNumber(stateProperty(component, name))}
value={stateProperty(component, name)}
onChange={e => component.handleChange(e, valueToNumber)}
{...props} />
<Form.Control.Feedback type="invalid">Must be a valid number or empty</Form.Control.Feedback>
</Form.Group>
}
function checkedToBool(t) {
if (t.checked) {
return true;
}
return undefined;
}
export function RequiredBoolean(component, label, name, defaultLabel) {
return <Form.Group as={Col}>
<Form.Check
label={label}
name={name}
checked={stateProperty(component, name)}
onChange={e => component.handleChange(e, checkedToBool)}
type="checkbox" />
</Form.Group>
}
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 <Form.Group as={Col}>
<Form.Label>{label}</Form.Label>
<Form.Control
name={name}
value={stateProperty(component, name)}
onChange={e => component.handleChange(e, optionalBooleanValue)}
as="select">
<option value="">{defaultLabel}</option>
<option value="true">yes</option>
<option value="false">no</option>
</Form.Control>
</Form.Group>
}
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 <Form.Group as={Col}>
<Form.Label>{label}</Form.Label>
<Form.Control
name={name}
value={listToMultilineString(stateProperty(component, name))}
onChange={e => component.handleChange(e, multilineStringToList)}
as="textarea"
rows="5">
</Form.Control>
<Form.Text className="text-muted">{helpText}</Form.Text>
</Form.Group>
}

View File

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