diff --git a/README.md b/README.md index 31680e7b..f3c27ab1 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ * [SonarQube](#sonarqube) * [Elasticsearch Hit Count](#elasticsearch-hit-count) * [GitHub Issue Count](#github-issue-count) + * [Eureka Health Status](#eureka-health-status) * [Available Themes](#available-themes) * [light](#light) * [dark](#dark) @@ -312,6 +313,36 @@ import GitHubIssueCount from '../components/github/issue-count' * `repository`: Name of the repository * `authKey`: Credential key, defined in [auth.js](./auth.js) +### [Eureka Health Status](./components/widgets/eureka/health-status.js) + +#### Example + +```javascript +import EurekaHealthStatus from '../components/widgets/eureka/health-status' + + +``` + +#### props + +* `title`: Widget title (Default: `Eureka Health Status`) +* `interval`: Refresh interval in milliseconds (Default: `360000`) +* `url`: Cross Origin Service URL +* `eurekaQuery`: Eureka Server URL +* `healthQuery`: Relative Path to Spring Boot Actuator Health endpoint +* `appsQuery`: Relative Path to Eureka Apps API endpoint [Eureka REST operations](https://github.com/Netflix/eureka/wiki/Eureka-REST-operations) +* `authKey`: Credential key, defined in [auth.js](./auth.js) +* `appNamePattern`: Name pattern the service-names have to start with +* `minimumInstances`: Number of instances for each service which are expected to run to be fine (Default: `2`) + ## Available Themes ### [light](./styles/light-theme.js) diff --git a/components/widgets/eureka/health-status.js b/components/widgets/eureka/health-status.js new file mode 100644 index 00000000..6aa39a6b --- /dev/null +++ b/components/widgets/eureka/health-status.js @@ -0,0 +1,168 @@ +import { Component } from 'react' +import fetch from 'isomorphic-unfetch' +import yup from 'yup' +import Widget from '../../widget' +import Table, { Th, Td } from '../../table' +import { basicAuthHeader } from '../../../lib/auth' +import styled from 'styled-components' + +const schema = yup.object().shape({ + url: yup.string().url().required(), + interval: yup.number(), + eurekaQuery: yup.string().required(), + appsQuery: yup.string().required(), + healthQuery: yup.string().required(), + title: yup.string(), + minimumInstances: yup.number(), + appNamePattern: yup.string(), + authKey: yup.string() +}) + +const EurekaDiv = styled.div` + background-color: ${props => props.hasError ? props.theme.palette.errorColor : props.theme.palette.canvasColor}; +` + +export default class EurekaHealthStatus extends Component { + static defaultProps = { + interval: 1000 * 60 * 60, + title: 'Eureka Health Status', + minimumInstances: 2, + appNamePattern: '' + } + + state = { + error: false, + loading: true, + appStatus: '', + infoMessage: '' + } + + constructor (props, context) { + super(props, context) + this.checkInstanceCount = this.checkInstanceCount.bind(this) + } + + componentDidMount () { + schema.validate(this.props) + .then(() => this.fetchInformation()) + .catch((err) => { + console.error(`${err.name} @ ${this.constructor.name}`, err.errors) + this.setState({ error: true, loading: false }) + }) + } + + componentWillUnmount () { + clearTimeout(this.timeout) + } + + checkInstanceCount (appList) { + const { appNamePattern, minimumInstances } = this.props + let hasError = false + appList.forEach(function (entry) { + if ((appNamePattern.length === 0 || entry.name.startsWith(appNamePattern)) && entry.instance.length < minimumInstances) { + hasError = true + } + }) + return hasError + } + + async checkInstanceHealth (url, appNamePattern, appList) { + let hasError = false + for (var i = 0; i < appList.length; i++) { + const app = appList[i] + if (appNamePattern.length === 0 || app.name.startsWith(appNamePattern)) { + for (var j = 0; j < app.instance.length; j++) { + try { + const curInstance = app.instance[j] + let opts = {headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }} + const resHealth = await fetch(url + curInstance.healthCheckUrl, opts) + const healthJson = await resHealth.json() + hasError = healthJson.status !== 'UP' + } catch (error) { + hasError = true + } + } + } + } + return hasError + } + + async fetchInformation () { + const { authKey, url, eurekaQuery, healthQuery, appsQuery, appNamePattern } = this.props + let opts = authKey ? { headers: basicAuthHeader(authKey) } : {} + + try { + const res = await fetch(`${url}${eurekaQuery}${healthQuery}`, opts) + const json = await res.json() + + let appStatus = '' + let infoMessage = '' + let hasError = json.status !== 'UP' + + if (!hasError) { + opts = {headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }} + + try { + const resApps = await fetch(`${url}${eurekaQuery}${appsQuery}`, opts) + const jsonApps = await resApps.json() + + hasError = jsonApps.applications.apps__hashcode.includes('DOWN') + + const appsStatus = jsonApps.applications.apps__hashcode.split('_') + if (appsStatus.length > 0 && appsStatus.length < 4) { + appStatus = `${appsStatus[0]}: ${appsStatus[1]}` + } else { + appStatus = `${appsStatus[0]}: ${appsStatus[1]} - ${appsStatus[2]}: ${appsStatus[3]}` + } + + if (!hasError) { + hasError = this.checkInstanceCount(jsonApps.applications.application) + if (hasError) { + infoMessage = 'Instance redundancy failed' + } + } + + if (!hasError) { + hasError = await this.checkInstanceHealth(url, appNamePattern, jsonApps.applications.application) + if (hasError) { + infoMessage = 'App health check failed' + } + } + } catch (error) { + hasError = true + } + } else { + infoMessage = 'Eureka health failed' + } + this.setState({ appStatus, infoMessage, hasError, error: false, loading: false }) + } catch (error) { + this.setState({ error: true, loading: false }) + } finally { + this.timeout = setTimeout(() => this.fetchInformation(), this.props.interval) + } + } + + render () { + const { error, loading, appStatus, infoMessage, hasError } = this.state + const { title } = this.props + + return ( + + + + + + + + + + + + + +
Apps{appStatus}
Info{infoMessage}
+
+
+ ) + } +}