Add history dialog in snitch dialog to allow viewing the editing history (#7321)

* Add history dialog in snitch dialog to allow viewing the editing history

* Improved CSS; better animation

* Use position: absolute instead of float: right to position element

* Removed author for history changes
This commit is contained in:
Qi Shu 2019-03-27 17:52:44 -07:00 committed by Clint Wylie
parent eeb3dbe79d
commit 18e5167245
10 changed files with 353 additions and 67 deletions

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { Button } from '@blueprintjs/core';
import { Button, Collapse } from '@blueprintjs/core';
import classNames from 'classnames';
import * as React from 'react';
import AceEditor from "react-ace";
@ -47,7 +47,7 @@ export const IconNames = {
ARROW_LEFT: "arrow-left" as "arrow-left",
CARET_RIGHT: "caret-right" as "caret-right",
TICK: "tick" as "tick",
ARROW_RIGHT: "right-arrow" as "right-arrow",
ARROW_RIGHT: "arrow-right" as "arrow-right",
TRASH: "trash" as "trash",
CARET_DOWN: "caret-down" as "caret-down",
ARROW_UP: "arrow-up" as "arrow-up",
@ -152,13 +152,15 @@ export class HTMLSelect extends React.Component<{ key?: string; style?: any; onC
}
}
export class TextArea extends React.Component<{ className?: string; onChange?: any; value?: string }, {}> {
export class TextArea extends React.Component<{ className?: string; onChange?: any; value?: string, readOnly?: boolean, style?: any}, {}> {
render() {
const { className, value, onChange } = this.props;
const { className, value, onChange, readOnly, style } = this.props;
return <textarea
readOnly={readOnly}
className={classNames("pt-input", className)}
value={value}
onChange={onChange}
style={style}
/>;
}
}
@ -267,6 +269,7 @@ interface JSONInputProps extends React.Props<any> {
onChange: (newJSONValue: any) => void;
value: any;
updateInputValidity: (valueValid: boolean) => void;
height?: string;
}
interface JSONInputState {
@ -298,7 +301,7 @@ export class JSONInput extends React.Component<JSONInputProps, JSONInputState> {
}
render() {
const { onChange, updateInputValidity } = this.props;
const { onChange, updateInputValidity, height } = this.props;
const { stringValue } = this.state;
return <AceEditor
className={"bp3-fill"}
@ -314,7 +317,7 @@ export class JSONInput extends React.Component<JSONInputProps, JSONInputState> {
focus
fontSize={12}
width={'100%'}
height={"8vh"}
height={height ? height : "8vh"}
showPrintMargin={false}
showGutter={false}
value={stringValue}
@ -330,3 +333,42 @@ export class JSONInput extends React.Component<JSONInputProps, JSONInputState> {
/>;
}
}
interface JSONCollapseProps extends React.Props<any> {
stringValue: string;
buttonText: string;
}
interface JSONCollapseState {
isOpen: boolean;
}
export class JSONCollapse extends React.Component<JSONCollapseProps, JSONCollapseState> {
constructor(props: any) {
super(props);
this.state = {
isOpen: false
};
}
render() {
const { stringValue, buttonText} = this.props;
const { isOpen } = this.state;
const prettyValue = JSON.stringify(JSON.parse(stringValue), undefined, 2);
return <div className={"json-collapse"}>
<Button
className={`pt-minimal ${isOpen ? " pt-active" : ""}`}
onClick={() => this.setState({isOpen: !isOpen})}
text={buttonText}
/>
<div>
<Collapse isOpen={isOpen}>
<TextArea
readOnly
value={prettyValue}
/>
</Collapse>
</div>
</div>;
}
}

View File

@ -17,8 +17,10 @@
*/
.table-column-selection {
float: right;
&.pt-popover-target{
position: absolute;
right: 0;
}
.pt-popover-content {
padding: 10px 10px 1px 10px;

View File

@ -17,7 +17,10 @@
*/
.coordinator-dynamic-config {
margin-top: 5vh;
&.pt-dialog {
margin-top: 5vh;
top: 5%;
}
.pt-dialog-body {
max-height: 70vh;

View File

@ -23,7 +23,7 @@ import * as React from 'react';
import { AutoForm } from '../components/auto-form';
import { IconNames } from '../components/filler';
import { AppToaster } from '../singletons/toaster';
import { getDruidErrorMessage } from '../utils';
import { getDruidErrorMessage, QueryManager } from '../utils';
import { SnitchDialog } from './snitch-dialog';
@ -35,18 +35,36 @@ export interface CoordinatorDynamicConfigDialogProps extends React.Props<any> {
export interface CoordinatorDynamicConfigDialogState {
dynamicConfig: Record<string, any> | null;
historyRecords: any[];
}
export class CoordinatorDynamicConfigDialog extends React.Component<CoordinatorDynamicConfigDialogProps, CoordinatorDynamicConfigDialogState> {
private historyQueryManager: QueryManager<string, any>;
constructor(props: CoordinatorDynamicConfigDialogProps) {
super(props);
this.state = {
dynamicConfig: null
dynamicConfig: null,
historyRecords: []
};
}
componentDidMount(): void {
componentDidMount() {
this.getClusterConfig();
this.historyQueryManager = new QueryManager({
processQuery: async (query) => {
const historyResp = await axios(`/druid/coordinator/v1/config/history?count=100`);
return historyResp.data;
},
onStateChange: ({ result, loading, error }) => {
this.setState({
historyRecords: result
});
}
});
this.historyQueryManager.runQuery(`dummy`);
}
async getClusterConfig() {
@ -67,13 +85,13 @@ export class CoordinatorDynamicConfigDialog extends React.Component<CoordinatorD
});
}
private saveClusterConfig = async (author: string, comment: string) => {
private saveClusterConfig = async (comment: string) => {
const { onClose } = this.props;
const newState: any = this.state.dynamicConfig;
try {
await axios.post("/druid/coordinator/v1/config", newState, {
headers: {
"X-Druid-Author": author,
"X-Druid-Author": "console",
"X-Druid-Comment": comment
}
});
@ -94,7 +112,7 @@ export class CoordinatorDynamicConfigDialog extends React.Component<CoordinatorD
render() {
const { onClose } = this.props;
const { dynamicConfig } = this.state;
const { dynamicConfig, historyRecords } = this.state;
return <SnitchDialog
className="coordinator-dynamic-config"
@ -102,6 +120,7 @@ export class CoordinatorDynamicConfigDialog extends React.Component<CoordinatorD
onSave={this.saveClusterConfig}
onClose={onClose}
title="Coordinator dynamic config"
historyRecords={historyRecords}
>
<p>
Edit the coordinator dynamic configuration on the fly.

View File

@ -0,0 +1,88 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.history-dialog {
&.pt-dialog {
width: 600px;
top: 5%;
}
.history-record-container {
padding: 15px 15px 0 15px;
h3 {
padding-left: 10px;
}
.no-record {
height: 25vh;
display: flex;
justify-content: center;
align-items: center;
}
.history-record-entries {
margin-bottom: 10px;
max-height: 60vh;
overflow: scroll;
.history-record-entry {
padding: 5px;
border-style: dot-dash;
word-wrap: break-word;
hr {
margin: 5px 0 5px 0;
}
.pt-card {
padding-bottom: 10px;
}
.history-record-title {
justify-content: space-between;
display: flex;
}
.history-record-comment-title {
margin-bottom: 5px;
}
.json-collapse {
button {
position: relative;
left: 86%;
margin-bottom: 5px;
}
textarea {
width: 100%;
height: 30vh;
margin-bottom: 5px;
}
}
}
}
}
}

View File

@ -0,0 +1,91 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Dialog } from "@blueprintjs/core";
import * as React from "react";
import { Card, JSONCollapse } from "../components/filler";
import "./history-dialog.scss";
interface HistoryDialogProps extends React.Props<any> {
historyRecords: any;
}
interface HistoryDialogState {
}
export class HistoryDialog extends React.Component<HistoryDialogProps, HistoryDialogState> {
constructor(props: HistoryDialogProps) {
super(props);
this.state = {
};
}
renderRecords() {
const {children, historyRecords} = this.props;
let content;
if (historyRecords.length === 0) {
content = <div className={"no-record"}>No history records available</div>;
} else {
content = <>
<h3>History</h3>
<div className={"history-record-entries"}>
{
historyRecords.map((record: any) => {
const auditInfo = record.auditInfo;
const auditTime = record.auditTime;
const formattedTime = auditTime.replace("T", " ").substring(0, auditTime.length - 5);
return <div key={record.auditTime} className={"history-record-entry"}>
<Card>
<div className={"history-record-title"}>
<h5>Change</h5>
<p>{formattedTime}</p>
</div>
<hr/>
<p>{auditInfo.comment === "" ? "(No comment)" : auditInfo.comment}</p>
<JSONCollapse
stringValue={record.payload}
buttonText={"Payload"}
/>
</Card>
</div>;
})
}
</div>
</>;
}
return <div className={"history-record-container"}>
{content}
{children}
</div>;
}
render(): React.ReactNode {
return <Dialog
isOpen
{...this.props}
className={"history-dialog"}
>
{this.renderRecords()}
</Dialog>;
}
}

View File

@ -23,7 +23,7 @@ import * as React from "react";
import { AutoForm } from "../components/auto-form";
import { IconNames } from "../components/filler";
import { AppToaster } from "../singletons/toaster";
import { getDruidErrorMessage } from "../utils";
import { getDruidErrorMessage, QueryManager } from "../utils";
import { SnitchDialog } from "./snitch-dialog";
@ -36,19 +36,37 @@ export interface OverlordDynamicConfigDialogProps extends React.Props<any> {
export interface OverlordDynamicConfigDialogState {
dynamicConfig: Record<string, any> | null;
allJSONValid: boolean;
historyRecords: any[];
}
export class OverlordDynamicConfigDialog extends React.Component<OverlordDynamicConfigDialogProps, OverlordDynamicConfigDialogState> {
private historyQueryManager: QueryManager<string, any>;
constructor(props: OverlordDynamicConfigDialogProps) {
super(props);
this.state = {
dynamicConfig: null,
allJSONValid: true
allJSONValid: true,
historyRecords: []
};
}
componentDidMount(): void {
componentDidMount() {
this.getConfig();
this.historyQueryManager = new QueryManager({
processQuery: async (query) => {
const historyResp = await axios(`/druid/indexer/v1/worker/history?count=100`);
return historyResp.data;
},
onStateChange: ({ result, loading, error }) => {
this.setState({
historyRecords: result
});
}
});
this.historyQueryManager.runQuery(`dummy`);
}
async getConfig() {
@ -69,13 +87,13 @@ export class OverlordDynamicConfigDialog extends React.Component<OverlordDynamic
});
}
private saveConfig = async (author: string, comment: string) => {
private saveConfig = async (comment: string) => {
const { onClose } = this.props;
const newState: any = this.state.dynamicConfig;
try {
await axios.post("/druid/indexer/v1/worker", newState, {
headers: {
"X-Druid-Author": author,
"X-Druid-Author": "console",
"X-Druid-Comment": comment
}
});
@ -96,7 +114,7 @@ export class OverlordDynamicConfigDialog extends React.Component<OverlordDynamic
render() {
const { onClose } = this.props;
const { dynamicConfig, allJSONValid } = this.state;
const { dynamicConfig, allJSONValid, historyRecords } = this.state;
return <SnitchDialog
className="overlord-dynamic-config"
@ -105,6 +123,7 @@ export class OverlordDynamicConfigDialog extends React.Component<OverlordDynamic
onClose={onClose}
title="Overlord dynamic config"
saveDisabled={!allJSONValid}
historyRecords={historyRecords}
>
<p>
Edit the overlord dynamic configuration on the fly.

View File

@ -22,6 +22,7 @@ import * as React from 'react';
import { FormGroup, IconNames } from '../components/filler';
import { Rule, RuleEditor } from '../components/rule-editor';
import { QueryManager } from "../utils";
import { SnitchDialog } from './snitch-dialog';
@ -43,28 +44,48 @@ export interface RetentionDialogProps extends React.Props<any> {
tiers: string[];
onEditDefaults: () => void;
onCancel: () => void;
onSave: (datasource: string, newRules: any[], author: string, comment: string) => void;
onSave: (datasource: string, newRules: any[], comment: string) => void;
}
export interface RetentionDialogState {
currentRules: any[];
historyRecords: any[];
}
export class RetentionDialog extends React.Component<RetentionDialogProps, RetentionDialogState> {
private historyQueryManager: QueryManager<string, any>;
constructor(props: RetentionDialogProps) {
super(props);
this.state = {
currentRules: props.rules
currentRules: props.rules,
historyRecords: []
};
}
private save = (author: string, comment: string) => {
componentDidMount() {
const { datasource } = this.props;
this.historyQueryManager = new QueryManager({
processQuery: async (query) => {
const historyResp = await axios(`/druid/coordinator/v1/rules/${datasource}/history`);
return historyResp.data;
},
onStateChange: ({ result, loading, error }) => {
this.setState({
historyRecords: result
});
}
});
this.historyQueryManager.runQuery(`dummy`);
}
private save = (comment: string) => {
const { datasource, onSave } = this.props;
const { currentRules } = this.state;
onSave(datasource, currentRules, author, comment);
onSave(datasource, currentRules, comment);
}
private changeRule = (newRule: any, index: number) => {
@ -140,7 +161,7 @@ export class RetentionDialog extends React.Component<RetentionDialogProps, Reten
render() {
const { datasource, onCancel, onEditDefaults } = this.props;
const { currentRules } = this.state;
const { currentRules, historyRecords } = this.state;
return <SnitchDialog
className="retention-dialog"
@ -152,6 +173,7 @@ export class RetentionDialog extends React.Component<RetentionDialogProps, Reten
title={`Edit retention rules: ${datasource}${datasource === '_default' ? ' (cluster defaults)' : ''}`}
onReset={this.reset}
onSave={this.save}
historyRecords={historyRecords}
>
<p>
Druid uses rules to determine what data should be retained in the cluster.

View File

@ -27,22 +27,23 @@ import {
import * as React from 'react';
import { FormGroup, IconNames } from '../components/filler';
import { localStorageGet, localStorageSet } from "../utils";
const druidEditingAuthor = "DRUID_EDITING_AUTHOR";
import { HistoryDialog } from "./history-dialog";
export interface SnitchDialogProps extends IDialogProps {
onSave: (author: string, comment: string) => void;
onSave: (comment: string) => void;
saveDisabled?: boolean;
onReset?: () => void;
historyRecords?: any[];
}
export interface SnitchDialogState {
author: string;
comment: string;
showFinalStep?: boolean;
saveDisabled?: boolean;
showHistory?: boolean;
}
export class SnitchDialog extends React.Component<SnitchDialogProps, SnitchDialogState> {
@ -51,48 +52,24 @@ export class SnitchDialog extends React.Component<SnitchDialogProps, SnitchDialo
this.state = {
comment: "",
author: "",
saveDisabled: true
};
}
componentDidMount(): void {
this.getDefaultAuthor();
}
save = () => {
const { onSave, onClose } = this.props;
const { author, comment } = this.state;
const { comment } = this.state;
onSave(author, comment);
onSave(comment);
if (onClose) onClose();
}
getDefaultAuthor() {
const author: string | null = localStorageGet(druidEditingAuthor);
if (author) {
this.setState({
author
});
}
}
changeAuthor(newAuthor: string) {
const { author, comment } = this.state;
this.setState({
author: newAuthor,
saveDisabled: !newAuthor || !comment
});
localStorageSet(druidEditingAuthor, newAuthor);
}
changeComment(newComment: string) {
const { author, comment } = this.state;
const { comment } = this.state;
this.setState({
comment: newComment,
saveDisabled: !author || !newComment
saveDisabled: !newComment
});
}
@ -104,7 +81,8 @@ export class SnitchDialog extends React.Component<SnitchDialogProps, SnitchDialo
back = () => {
this.setState({
showFinalStep: false
showFinalStep: false,
showHistory: false
});
}
@ -114,15 +92,18 @@ export class SnitchDialog extends React.Component<SnitchDialogProps, SnitchDialo
});
}
goToHistory = () => {
this.setState({
showHistory: true
});
}
renderFinalStep() {
const { onClose, children } = this.props;
const { saveDisabled, author, comment } = this.state;
const { saveDisabled, comment } = this.state;
return <Dialog {...this.props}>
<div className={`dialog-body ${Classes.DIALOG_BODY}`}>
<FormGroup label={"Who is making this change?"}>
<InputGroup value={author} onChange={(e: any) => this.changeAuthor(e.target.value)}/>
</FormGroup>
<FormGroup label={"Why are you making this change?"} className={"comment"}>
<InputGroup
className="pt-large"
@ -139,11 +120,28 @@ export class SnitchDialog extends React.Component<SnitchDialogProps, SnitchDialo
</Dialog>;
}
renderHistoryDialog() {
const { historyRecords } = this.props;
return <HistoryDialog
{...this.props}
historyRecords={historyRecords}
>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button onClick={this.back} iconName={IconNames.ARROW_LEFT}>Back</Button>
</div>
</HistoryDialog>;
}
renderActions(saveDisabled?: boolean) {
const { onReset } = this.props;
const { onReset, historyRecords } = this.props;
const { showFinalStep } = this.state;
return <div className={Classes.DIALOG_FOOTER_ACTIONS}>
{showFinalStep || historyRecords === undefined
? null
: <Button style={{position: "absolute", left: "5px"}} className={"pt-minimal"} text="History" onClick={this.goToHistory}/>
}
{ showFinalStep
? <Button onClick={this.back} iconName={IconNames.ARROW_LEFT}>Back</Button>
: onReset ? <Button onClick={this.reset} intent={"none" as any}>Reset</Button> : null
@ -158,14 +156,16 @@ export class SnitchDialog extends React.Component<SnitchDialogProps, SnitchDialo
render() {
const { onClose, className, children, saveDisabled } = this.props;
const { showFinalStep } = this.state;
const { showFinalStep, showHistory } = this.state;
if (showFinalStep) return this.renderFinalStep();
if (showHistory) return this.renderHistoryDialog();
return <Dialog isOpen inline {...this.props}>
<div className={Classes.DIALOG_BODY}>
{children}
</div>
<div className={Classes.DIALOG_FOOTER}>
{this.renderActions(saveDisabled)}
</div>

View File

@ -249,11 +249,11 @@ GROUP BY 1`);
</AsyncActionDialog>;
}
private saveRules = async (datasource: string, rules: any[], author: string, comment: string) => {
private saveRules = async (datasource: string, rules: any[], comment: string) => {
try {
await axios.post(`/druid/coordinator/v1/rules/${datasource}`, rules, {
headers: {
"X-Druid-Author": author,
"X-Druid-Author": "console",
"X-Druid-Comment": comment
}
});