From 73a6aaeeac5c451ddf67afa16797e8ceedd4f887 Mon Sep 17 00:00:00 2001 From: Hattori Keigo Date: Fri, 10 Aug 2018 18:41:12 +0900 Subject: [PATCH] Add services list view --- frontend/src/apis/index.tsx | 6 +- .../App/Application/SideMenu/index.tsx | 10 + .../App/Services/ServicesDeleteForm.tsx | 194 +++++++++ .../App/Services/ServicesStatusTable.tsx | 161 ++++++++ .../src/components/App/Services/index.tsx | 377 ++++++++++++++++++ frontend/src/components/App/index.tsx | 3 + 6 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/App/Services/ServicesDeleteForm.tsx create mode 100644 frontend/src/components/App/Services/ServicesStatusTable.tsx create mode 100644 frontend/src/components/App/Services/index.tsx diff --git a/frontend/src/apis/index.tsx b/frontend/src/apis/index.tsx index be9112c..7a619a1 100644 --- a/frontend/src/apis/index.tsx +++ b/frontend/src/apis/index.tsx @@ -15,7 +15,9 @@ export class Service { public id: string = '', public name: string = '', public serviceLevel: string = '', - public modelId: string = null + public modelId: string = null, + public host: string = '', + public description: string = '', ) { } } @@ -323,6 +325,8 @@ export async function fetchAllServices(params: FetchServicesParam) { name: variable.display_name, serviceLevel: variable.service_level, modelId: variable.model_id, + host: variable.host, + description: variable.description, } }) return APICore.getRequest(`${process.env.API_HOST}:${process.env.API_PORT}/api/applications/${params.applicationId}/services`, convert) diff --git a/frontend/src/components/App/Application/SideMenu/index.tsx b/frontend/src/components/App/Application/SideMenu/index.tsx index 053bd83..852c138 100644 --- a/frontend/src/components/App/Application/SideMenu/index.tsx +++ b/frontend/src/components/App/Application/SideMenu/index.tsx @@ -12,6 +12,16 @@ class SideMenu extends React.Component { path: 'dashboard', icon: 'ship' }, + { + text: 'Services', + path: 'services', + icon: 'server' + }, + { + text: 'Models', + path: 'models', + icon: 'database' + }, ] }, ] diff --git a/frontend/src/components/App/Services/ServicesDeleteForm.tsx b/frontend/src/components/App/Services/ServicesDeleteForm.tsx new file mode 100644 index 0000000..d7220f5 --- /dev/null +++ b/frontend/src/components/App/Services/ServicesDeleteForm.tsx @@ -0,0 +1,194 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { Button } from 'reactstrap' +import { reduxForm, InjectedFormProps } from 'redux-form' + +import { APIRequest } from '@src/apis/Core' +import { Service } from '@src/apis' +import ServicesStatusTable from './ServicesStatusTable' +import { ControlMode } from './index' + +class ServicesDeleteForm extends React.Component { + constructor(props, context) { + super(props, context) + + this.handleDiscardChanges = this.handleDiscardChanges.bind(this) + } + + componentWillReceiveProps(nextProps: ServicesDeleteFormProps) { + const { mode, pristine, changeMode } = nextProps + + if (mode === ControlMode.VIEW_SERVICES_STATUS && !pristine) { + changeMode(ControlMode.SELECT_TARGETS) + } else if (mode === ControlMode.SELECT_TARGETS && pristine) { + changeMode(ControlMode.VIEW_SERVICES_STATUS) + } + } + + render() { + const { + onSubmit, + handleSubmit, + } = this.props + + return ( +
+
+ {this.renderDiscardButton()} +
+ +
+ {this.renderSubmitButtons()} + + ) + } + + renderDiscardButton = () => { + const { mode } = this.props + + switch (mode) { + case ControlMode.SELECT_TARGETS: + return ( + + ) + default: + return ( +
+
+ ) + } + } + + /** + * Render submit button(s) + * + * Show delete button if selected targets exist + * Show save button if editing deploy status + */ + renderSubmitButtons(): JSX.Element { + const { + mode, + submitting, + pristine + } = this.props + + const showSubmitButton: boolean = mode !== ControlMode.VIEW_SERVICES_STATUS + + if (!showSubmitButton) { + return null + } + + const paramsMap = { + [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Services' }, + } + + // Submit button element(s) + const buttons = (params) => ( +
+ +
+ ) + + const submittingLoader = ( +
+
+ Submitting... +
+ ) + + return submitting ? submittingLoader : buttons(paramsMap[mode]) + } + + renderSubmitButtonElements() { + const { + submitting, + pristine + } = this.props + + const paramsMap = { + [ControlMode.SELECT_TARGETS]: { color: 'danger', icon: 'trash', text: 'Delete Services' }, + } + + return ( +
+ +
+ ) + } + + // Handle event methods + + handleDiscardChanges(event): void { + const { changeMode, reset } = this.props + reset() + changeMode(ControlMode.VIEW_SERVICES_STATUS) + } +} + +interface ServicesDeleteFormCustomProps { + applicationType: string + applicationId + mode: ControlMode + services: Service[] + onSubmit: (e) => Promise + changeMode: (mode: ControlMode) => void +} + +interface StateProps { + initialValues: { + status + delete + } +} + +const mapStateToProps = (state: any, extraProps: ServicesDeleteFormCustomProps) => { + // Map of service ID to delete flag + const initialDeleteStatus: { [x: string]: boolean } = + extraProps.services + .map((service) => ({[service.id]: false})) + .reduce((l, r) => Object.assign(l, r), {}) + + return { + ...state.form, + initialValues: { + delete: { + services: initialDeleteStatus + } + } + } +} + +const mapDispatchToProps = (dispatch): {} => { + return { } +} + +type ServicesDeleteFormProps + = StateProps & ServicesDeleteFormCustomProps & InjectedFormProps<{}, ServicesDeleteFormCustomProps> + +export default connect(mapStateToProps, mapDispatchToProps)( + reduxForm<{}, ServicesDeleteFormCustomProps>( + { + form: 'deployStatusForm' + } + )(ServicesDeleteForm) +) diff --git a/frontend/src/components/App/Services/ServicesStatusTable.tsx b/frontend/src/components/App/Services/ServicesStatusTable.tsx new file mode 100644 index 0000000..0356543 --- /dev/null +++ b/frontend/src/components/App/Services/ServicesStatusTable.tsx @@ -0,0 +1,161 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { CustomInput, Table, Row } from 'reactstrap' +import { Field, InjectedFormProps } from 'redux-form' +import { Link } from 'react-router-dom' + +import { Service } from '@src/apis' +import { ControlMode } from './index' + +/** + * Table for showing services status + */ +class ServicesStatusTable extends React.Component { + constructor(props, context) { + super(props, context) + + this.state = { + tooltipOpen: {} + } + } + + render() { + const { services } = this.props + + return ( + + {this.renderTableHead()} + {this.renderTableBody(services)} +
+ ) + } + + toggleTooltip(tag) { + return () => { + const nextTooltipOpen = { + ...this.state.tooltipOpen, + [tag]: !this.state.tooltipOpen[tag] + } + + this.setState({ + tooltipOpen: nextTooltipOpen + }) + } + } + + /** + * Render head row of the table + */ + renderTableHead = () => { + return ( + + + NameService LevelDescriptionHost + + + ) + } + + /** + * Render body of the table + * + * Render Service names + * Each Service is rendered with a deploy check box on viewing/deleting mode + * @param services Services to be shown (Currently show all, but should be filtered) + */ + renderTableBody = (services) => { + const { mode, applicationType, applicationId } = this.props + + // Button to delete Service (for deleting k8s services) + const deleteCheckButton = (serviceName: string, serviceId: string) => { + return ( + + { applicationType === 'kubernetes' ? + + : null } + + {serviceName} + + + ) + } + + const renderButton = (serviceName, serviceId) => { + const renderMap = { + [ControlMode.VIEW_SERVICES_STATUS] : deleteCheckButton(serviceName, serviceId), + [ControlMode.SELECT_TARGETS] : deleteCheckButton(serviceName, serviceId), + } + return renderMap[mode] + } + + return ( + + {services.map( + (service) => ( + + + {renderButton(service.name, service.id)} + + + {service.serviceLevel} + + + {service.description} + + + {service.host} + + + ) + )} + + ) + } + +} + +const CustomCheckBox = (props) => { + const { input, id, label } = props + + return ( + + ) +} + +interface ServicesStatusFormCustomProps { + applicationType: string + applicationId + services: Service[], + mode: ControlMode, +} + +export interface DispatchProps { + dispatchChange +} + +const mapDispatchToProps = (dispatch): DispatchProps => { + return { + dispatchChange: (field, value, changeMethod) => dispatch(changeMethod(field, value)) + } +} + +const mapStateToProps = (state: any, extraProps: ServicesStatusFormCustomProps) => { + return {} +} + +type ServicesStatusProps = DispatchProps & ServicesStatusFormCustomProps & InjectedFormProps<{}, ServicesStatusFormCustomProps> + +export default connect(mapStateToProps, mapDispatchToProps)(ServicesStatusTable) diff --git a/frontend/src/components/App/Services/index.tsx b/frontend/src/components/App/Services/index.tsx new file mode 100644 index 0000000..da6ce94 --- /dev/null +++ b/frontend/src/components/App/Services/index.tsx @@ -0,0 +1,377 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import { withRouter, RouteComponentProps } from 'react-router' +import { Link } from 'react-router-dom' +import { Button, Modal, ModalBody, ModalHeader, Row, Col } from 'reactstrap' + +import { APIRequest, isAPISucceeded, isAPIFailed } from '@src/apis/Core' +import { Service, SynKubernetesStatusParam, Application } from '@src/apis' +import { + addNotification, + fetchApplicationByIdDispatcher, + fetchAllServicesDispatcher, + deleteKubernetesServicesDispatcher, + syncKubernetesStatusDispatcher +} from '@src/actions' +import ServicesDeleteForm from './ServicesDeleteForm' +import { APIRequestResultsRenderer } from '@common/APIRequestResultsRenderer' + +export enum ControlMode { + VIEW_SERVICES_STATUS, + SELECT_TARGETS, +} + +type ServicesStatusProps = DispatchProps & StateProps & RouteComponentProps<{applicationId: string}> + +class Services extends React.Component { + constructor(props, context) { + super(props, context) + + this.state = { + controlMode: ControlMode.VIEW_SERVICES_STATUS, + isDeleteServicesModalOpen: false, + selectedData: { services: [] }, + submitted: false, + syncSubmitted: false, + syncNotified: false + } + + this.onSubmitDelete = this.onSubmitDelete.bind(this) + this.deleteKubernetesServices = this.deleteKubernetesServices.bind(this) + this.toggleDeleteServicesModal = this.toggleDeleteServicesModal.bind(this) + this.syncServices = this.syncServices.bind(this) + this.renderServices = this.renderServices.bind(this) + this.changeMode = this.changeMode.bind(this) + this.complete = this.complete.bind(this) + } + + componentWillMount() { + const { applicationId } = this.props.match.params + + this.props.fetchApplicationById(applicationId) + this.props.fetchAllServices(applicationId) + } + + componentWillReceiveProps(nextProps: ServicesStatusProps) { + const { + deleteKubernetesServicesStatus, + syncKubernetesServicesStatusStatus + } = nextProps + const { controlMode, submitted } = this.state + + const checkAllApiResultStatus = + (result: APIRequest) => + isAPISucceeded(result) && + result.result.reduce((p, c) => (p && c)) + + if (submitted && controlMode === ControlMode.SELECT_TARGETS) { + if (checkAllApiResultStatus(deleteKubernetesServicesStatus)) { + this.complete({ color: 'success', message: 'Successfully changed deletion' }) + } else { + this.complete({ color: 'error', message: 'Something went wrong, try again later' }) + } + } + + this.checkAndNotifyAPIResult( + syncKubernetesServicesStatusStatus, + 'syncSubmitted', 'syncNotified', + 'Successfully synced application' + ) + } + + checkAndNotifyAPIResult(status, submitted: string, notified: string, notificationText) { + const submittedFlag: boolean = this.state[submitted] + const notifiedFlag: boolean = this.state[notified] + + if (submittedFlag && !notifiedFlag) { + const succeeded: boolean = isAPISucceeded(status) && status.result + const failed: boolean = (isAPISucceeded(status) && !status.result) || + isAPIFailed(status) + + if (succeeded) { + this.setState({[submitted]: false, [notified]: true}) + this.complete({ color: 'success', message: notificationText }) + } else if (failed) { + this.setState({[submitted]: false, [notified]: true}) + this.complete({ color: 'error', message: 'Something went wrong. Try again later' }) + } + } + } + + // Render methods + + render(): JSX.Element { + const { application, services } = this.props + if ( this.props.match.params.applicationId === 'add' ) { + return null + } + return ( + + ) + } + + /** + * Render services status / related form fields + * with fetched API results + * + * @param fetchedResults Fetched data from APIs + */ + renderServices(fetchedResults) { + const { controlMode } = this.state + const { + onSubmitNothing, + onSubmitDelete, + changeMode + } = this + + const { kubernetesId, name } = fetchedResults.application + const { applicationId } = this.props.match.params + + const services: Service[] = fetchedResults.services + const onSubmitMap = { + [ControlMode.VIEW_SERVICES_STATUS]: onSubmitNothing, + [ControlMode.SELECT_TARGETS]: onSubmitDelete, + } + + return ( + this.renderContent( + , + name, + kubernetesId + ) + ) + } + + renderContent = (content: JSX.Element, applicationName, kubernetesId): JSX.Element => { + return ( +
+ {this.renderTitle(applicationName, kubernetesId)} +

+ + Services +

+
+ {content} + { + this.state.controlMode === ControlMode.SELECT_TARGETS + ? this.renderConfirmDeleteHostModal() + : null + } +
+ ) + } + + renderTitle = (applicationName, kubernetesId): JSX.Element => { + return ( + + +

+ + {applicationName} +

+ + + {kubernetesId ? this.renderKubernetesControlButtons(kubernetesId) : null} + +
+ ) + } + + renderKubernetesControlButtons(kubernetesId) { + const { push } = this.props.history + const { syncServices } = this + const { applicationId } = this.props.match.params + + return ( + + + {` `} + + + ) + } + + renderConfirmDeleteHostModal(): JSX.Element { + const { isDeleteServicesModalOpen } = this.state + + const cancel = () => { + this.toggleDeleteServicesModal() + } + + const executeDeletion = (event) => { + this.deleteKubernetesServices(this.state.selectedData.services) + this.toggleDeleteServicesModal() + } + + return ( + + Delete Kubernetes Services + + Are you sure to delete? + +
+ + +
+
+ ) + } + + syncServices(kubernetesId): void { + const { applicationId } = this.props.match.params + + this.setState({ syncSubmitted: true, syncNotified: false }) + this.props.syncKubernetesServicesStatus({applicationId, kubernetesId}) + } + + // Event handing methods + toggleDeleteServicesModal(): void { + this.setState({ + isDeleteServicesModalOpen: !this.state.isDeleteServicesModalOpen + }) + } + + onSubmitNothing(params): void { + this.setState({}) + } + + /** + * Handle submit and call API to delete services + * Currently only supports to delete k8s services + * + * @param params + */ + onSubmitDelete(params): void { + this.setState({ + isDeleteServicesModalOpen: true, + selectedData: { + services: params.delete.services, + } + }) + } + + deleteKubernetesServices(params): Promise { + const { deleteKubernetesServices } = this.props + const { applicationId } = this.props.match.params + + const apiParams = + Object.entries(params) + .filter(([key, value]) => (value)) + .map( + ([key, value]) => ( + { + applicationId, + serviceId: key + })) + + this.setState({ submitted: true }) + + return deleteKubernetesServices(apiParams) + } + + // Utils + changeMode(mode: ControlMode) { + this.setState({ controlMode: mode }) + } + + /** + * Reload services status + * + * Fetch services through API again + */ + complete(param) { + const { + fetchAllServices + } = this.props + const { + applicationId + } = this.props.match.params + + this.props.addNotification(param) + fetchAllServices(applicationId) + this.setState({ + controlMode: ControlMode.VIEW_SERVICES_STATUS, + submitted: false, + selectedData: { services: [] } + }) + } +} + +export interface StateProps { + application: APIRequest + services: APIRequest + deleteKubernetesServicesStatus: APIRequest + syncKubernetesServicesStatusStatus: APIRequest +} + +const mapStateToProps = (state): StateProps => { + const props = { + application: state.fetchApplicationByIdReducer.applicationById, + services: state.fetchAllServicesReducer.services, + deleteKubernetesServicesStatus: state.deleteKubernetesServicesReducer.deleteKubernetesServices, + syncKubernetesServicesStatusStatus: state.syncKubernetesStatusReducer.syncKubernetesStatus + } + return props +} + +export interface DispatchProps { + addNotification + syncKubernetesServicesStatus + fetchApplicationById: (id: string) => Promise + fetchAllServices: (applicationId: string) => Promise + deleteKubernetesServices: (params) => Promise +} + +const mapDispatchToProps = (dispatch): DispatchProps => { + return { + addNotification: (params) => dispatch(addNotification(params)), + fetchApplicationById: (id: string) => fetchApplicationByIdDispatcher(dispatch, { id }), + fetchAllServices: (applicationId: string) => fetchAllServicesDispatcher(dispatch, { applicationId }), + deleteKubernetesServices: (params) => deleteKubernetesServicesDispatcher(dispatch, params), + syncKubernetesServicesStatus: (params: SynKubernetesStatusParam) => syncKubernetesStatusDispatcher(dispatch, params), + } +} + +export default withRouter( + connect>( + mapStateToProps, mapDispatchToProps + )(Services)) diff --git a/frontend/src/components/App/index.tsx b/frontend/src/components/App/index.tsx index 7dbeb28..68f09a3 100644 --- a/frontend/src/components/App/index.tsx +++ b/frontend/src/components/App/index.tsx @@ -8,6 +8,7 @@ import Applications from './Applications' import SaveApplication from './SaveApplication' import Application from './Application' import Deploy from './Deploy' +import Services from './Services' import Service from './Service' import Settings from './Settings' import Login from './Login' @@ -78,6 +79,8 @@ const ApplicationRoute = () => ( + + } />