WIP: status page - API and UI (#6243)
* status page initial commit Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * refactor useFetch Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * refactoring Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * adding tests Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * snapshot testing Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * fix wrong go files formatting Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * change the snapshot library Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * update api paths Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * move test folder outside src Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * useFetches tests Signed-off-by: blalov <boyko.lalov@tick42.com> * sticky navbar Signed-off-by: Boyko Lalov <boyskila@gmail.com> Signed-off-by: blalov <boyko.lalov@tick42.com> * handle runtimeInfo error on Gather() and add json tags to RuntimeInfo struct Signed-off-by: blalov <boyko.lalov@tick42.com> * refactor alert managers section Signed-off-by: blalov <boyko.lalov@tick42.com>pull/6262/head
parent
ca9fce46a3
commit
cb7cbad5f9
@ -0,0 +1,55 @@ |
||||
import useFetches from './useFetches'; |
||||
import { renderHook } from '@testing-library/react-hooks'; |
||||
|
||||
describe('useFetches', () => { |
||||
beforeEach(() => { |
||||
fetchMock.resetMocks(); |
||||
}); |
||||
it('should can handle multiple requests', async done => { |
||||
fetchMock.mockResponse(JSON.stringify({ satus: 'success', data: { id: 1 } })); |
||||
const { result, waitForNextUpdate } = renderHook(useFetches, { initialProps: ['/foo/bar', '/foo/bar', '/foo/bar'] }); |
||||
await waitForNextUpdate(); |
||||
expect(result.current.response).toHaveLength(3); |
||||
done(); |
||||
}); |
||||
it('should can handle success flow -> isLoading=true, response=[data, data], isLoading=false', async done => { |
||||
fetchMock.mockResponse(JSON.stringify({ satus: 'success', data: { id: 1 } })); |
||||
const { result, waitForNextUpdate } = renderHook(useFetches, { initialProps: ['/foo/bar'] }); |
||||
expect(result.current.isLoading).toEqual(true); |
||||
await waitForNextUpdate(); |
||||
expect(result.current.response).toHaveLength(1); |
||||
expect(result.current.isLoading).toEqual(false); |
||||
done(); |
||||
}); |
||||
it('should isLoading remains true on empty response', async done => { |
||||
fetchMock.mockResponse(jest.fn()); |
||||
const { result, waitForNextUpdate } = renderHook(useFetches, { initialProps: ['/foo/bar'] }); |
||||
expect(result.current.isLoading).toEqual(true); |
||||
await waitForNextUpdate(); |
||||
setTimeout(() => { |
||||
expect(result.current.isLoading).toEqual(true); |
||||
done(); |
||||
}, 1000); |
||||
}); |
||||
it('should set error message when response fail', async done => { |
||||
fetchMock.mockReject(new Error('errr')); |
||||
const { result, waitForNextUpdate } = renderHook(useFetches, { initialProps: ['/foo/bar'] }); |
||||
expect(result.current.isLoading).toEqual(true); |
||||
await waitForNextUpdate(); |
||||
expect(result.current.error!.message).toEqual('errr'); |
||||
expect(result.current.isLoading).toEqual(true); |
||||
done(); |
||||
}); |
||||
it('should throw an error if array is empty', async done => { |
||||
try { |
||||
useFetches([]); |
||||
const { result, waitForNextUpdate } = renderHook(useFetches, { initialProps: [] }); |
||||
await waitForNextUpdate().then(done); |
||||
expect(result.error.message).toEqual("Doesn't have url to fetch."); |
||||
done(); |
||||
} catch (e) { |
||||
} finally { |
||||
done(); |
||||
} |
||||
}); |
||||
}); |
||||
@ -0,0 +1,36 @@ |
||||
import { useState, useEffect } from 'react'; |
||||
|
||||
const useFetches = <R extends any>(urls: string[], options?: RequestInit) => { |
||||
if (!urls.length) { |
||||
throw new Error("Doesn't have url to fetch."); |
||||
} |
||||
const [response, setResponse] = useState<R[]>(); |
||||
const [error, setError] = useState<Error>(); |
||||
|
||||
useEffect(() => { |
||||
const fetchData = async () => { |
||||
try { |
||||
const responses: R[] = await Promise.all( |
||||
urls |
||||
.map(async url => { |
||||
const res = await fetch(url, options); |
||||
if (!res.ok) { |
||||
throw new Error(res.statusText); |
||||
} |
||||
const result = await res.json(); |
||||
return result.data; |
||||
}) |
||||
.filter(Boolean) // Remove falsy values
|
||||
); |
||||
setResponse(responses); |
||||
} catch (error) { |
||||
setError(error); |
||||
} |
||||
}; |
||||
fetchData(); |
||||
}, [urls, options]); |
||||
|
||||
return { response, error, isLoading: !response || !response.length }; |
||||
}; |
||||
|
||||
export default useFetches; |
||||
@ -0,0 +1,72 @@ |
||||
import * as React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
import { Status } from '.'; |
||||
import { Alert } from 'reactstrap'; |
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; |
||||
import * as useFetch from '../hooks/useFetches'; |
||||
import toJson from 'enzyme-to-json'; |
||||
|
||||
describe('Status', () => { |
||||
afterEach(() => jest.restoreAllMocks()); |
||||
it('should render spinner while waiting data', () => { |
||||
const wrapper = shallow(<Status />); |
||||
expect(wrapper.find(FontAwesomeIcon)).toHaveLength(1); |
||||
}); |
||||
it('should render Alert on error', () => { |
||||
(useFetch as any).default = jest.fn().mockImplementation(() => ({ error: new Error('foo') })); |
||||
const wrapper = shallow(<Status />); |
||||
expect(wrapper.find(Alert)).toHaveLength(1); |
||||
}); |
||||
it('should fetch proper API endpoints', () => { |
||||
const useFetchSpy = jest.spyOn(useFetch, 'default'); |
||||
shallow(<Status />); |
||||
expect(useFetchSpy).toHaveBeenCalledWith([ |
||||
'../api/v1/status/runtimeinfo', |
||||
'../api/v1/status/buildinfo', |
||||
'../api/v1/alertmanagers', |
||||
]); |
||||
}); |
||||
describe('Snapshot testing', () => { |
||||
const response = [ |
||||
{ |
||||
startTime: '2019-10-30T22:03:23.247913868+02:00', |
||||
CWD: '/home/boyskila/Desktop/prometheus', |
||||
reloadConfigSuccess: true, |
||||
lastConfigTime: '2019-10-30T22:03:23+02:00', |
||||
chunkCount: 1383, |
||||
timeSeriesCount: 461, |
||||
corruptionCount: 0, |
||||
goroutineCount: 37, |
||||
GOMAXPROCS: 4, |
||||
GOGC: '', |
||||
GODEBUG: '', |
||||
storageRetention: '15d', |
||||
}, |
||||
{ |
||||
version: '', |
||||
revision: '', |
||||
branch: '', |
||||
buildUser: '', |
||||
buildDate: '', |
||||
goVersion: 'go1.13.3', |
||||
}, |
||||
{ |
||||
activeAlertmanagers: [ |
||||
{ url: 'https://1.2.3.4:9093/api/v1/alerts' }, |
||||
{ url: 'https://1.2.3.5:9093/api/v1/alerts' }, |
||||
{ url: 'https://1.2.3.6:9093/api/v1/alerts' }, |
||||
{ url: 'https://1.2.3.7:9093/api/v1/alerts' }, |
||||
{ url: 'https://1.2.3.8:9093/api/v1/alerts' }, |
||||
{ url: 'https://1.2.3.9:9093/api/v1/alerts' }, |
||||
], |
||||
droppedAlertmanagers: [], |
||||
}, |
||||
]; |
||||
it('should match table snapshot', () => { |
||||
(useFetch as any).default = jest.fn().mockImplementation(() => ({ response })); |
||||
const wrapper = shallow(<Status />); |
||||
expect(toJson(wrapper)).toMatchSnapshot(); |
||||
jest.restoreAllMocks(); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -1,6 +1,108 @@ |
||||
import React, { FC } from 'react'; |
||||
import React, { FC, Fragment } from 'react'; |
||||
import { RouteComponentProps } from '@reach/router'; |
||||
import { Table, Alert } from 'reactstrap'; |
||||
import useFetches from '../hooks/useFetches'; |
||||
|
||||
const Status: FC<RouteComponentProps> = () => <div>Status page</div>; |
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; |
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'; |
||||
|
||||
export default Status; |
||||
const ENDPOINTS = ['../api/v1/status/runtimeinfo', '../api/v1/status/buildinfo', '../api/v1/alertmanagers']; |
||||
const sectionTitles = ['Runtime Information', 'Build Information', 'Alertmanagers']; |
||||
|
||||
interface StatusConfig { |
||||
[k: string]: { title?: string; customizeValue?: (v: any) => any; customRow?: boolean; skip?: boolean }; |
||||
} |
||||
|
||||
type StatusPageState = Array<{ [k: string]: string }>; |
||||
|
||||
export const statusConfig: StatusConfig = { |
||||
startTime: { title: 'Start time', customizeValue: (v: string) => new Date(v).toUTCString() }, |
||||
CWD: { title: 'Working directory' }, |
||||
reloadConfigSuccess: { |
||||
title: 'Configuration reload', |
||||
customizeValue: (v: boolean) => (v ? 'Successful' : 'Unsuccessful'), |
||||
}, |
||||
lastConfigTime: { title: 'Last successful configuration reload' }, |
||||
chunkCount: { title: 'Head chunks' }, |
||||
timeSeriesCount: { title: 'Head time series' }, |
||||
corruptionCount: { title: 'WAL corruptions' }, |
||||
goroutineCount: { title: 'Goroutines' }, |
||||
storageRetention: { title: 'Storage retention' }, |
||||
activeAlertmanagers: { |
||||
customRow: true, |
||||
customizeValue: (alertMgrs: { url: string }[]) => { |
||||
return ( |
||||
<Fragment key="alert-managers"> |
||||
<tr> |
||||
<th>Endpoint</th> |
||||
</tr> |
||||
{alertMgrs.map(({ url }) => { |
||||
const { origin, pathname } = new URL(url); |
||||
return ( |
||||
<tr key={url}> |
||||
<td> |
||||
<a href={url}>{origin}</a> |
||||
{pathname} |
||||
</td> |
||||
</tr> |
||||
); |
||||
})} |
||||
</Fragment> |
||||
); |
||||
}, |
||||
}, |
||||
droppedAlertmanagers: { skip: true }, |
||||
}; |
||||
|
||||
const Status = () => { |
||||
const { response: data, error, isLoading } = useFetches<StatusPageState[]>(ENDPOINTS); |
||||
if (error) { |
||||
return ( |
||||
<Alert color="danger"> |
||||
<strong>Error:</strong> Error fetching status: {error.message} |
||||
</Alert> |
||||
); |
||||
} else if (isLoading) { |
||||
return ( |
||||
<FontAwesomeIcon |
||||
size="3x" |
||||
icon={faSpinner} |
||||
spin |
||||
className="position-absolute" |
||||
style={{ transform: 'translate(-50%, -50%)', top: '50%', left: '50%' }} |
||||
/> |
||||
); |
||||
} |
||||
return data |
||||
? data.map((statuses, i) => { |
||||
return ( |
||||
<Fragment key={i}> |
||||
<h2>{sectionTitles[i]}</h2> |
||||
<Table className="h-auto" size="sm" bordered striped> |
||||
<tbody> |
||||
{Object.entries(statuses).map(([k, v]) => { |
||||
const { title = k, customizeValue = (val: any) => val, customRow, skip } = statusConfig[k] || {}; |
||||
if (skip) { |
||||
return null; |
||||
} |
||||
if (customRow) { |
||||
return customizeValue(v); |
||||
} |
||||
return ( |
||||
<tr key={k}> |
||||
<th className="capitalize-title" style={{ width: '35%' }}> |
||||
{title} |
||||
</th> |
||||
<td className="text-break">{customizeValue(v)}</td> |
||||
</tr> |
||||
); |
||||
})} |
||||
</tbody> |
||||
</Table> |
||||
</Fragment> |
||||
); |
||||
}) |
||||
: null; |
||||
}; |
||||
|
||||
export default Status as FC<RouteComponentProps>; |
||||
|
||||
@ -0,0 +1,465 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`Status Snapshot testing should match table snapshot 1`] = ` |
||||
Array [ |
||||
<Fragment |
||||
key="0" |
||||
> |
||||
<h2> |
||||
Runtime Information |
||||
</h2> |
||||
<Table |
||||
bordered={true} |
||||
className="h-auto" |
||||
responsiveTag="div" |
||||
size="sm" |
||||
striped={true} |
||||
tag="table" |
||||
> |
||||
<tbody> |
||||
<tr |
||||
key="startTime" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
Start time |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
Wed, 30 Oct 2019 20:03:23 GMT |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="CWD" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
Working directory |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
/home/boyskila/Desktop/prometheus |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="reloadConfigSuccess" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
Configuration reload |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
Successful |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="lastConfigTime" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
Last successful configuration reload |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
2019-10-30T22:03:23+02:00 |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="chunkCount" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
Head chunks |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
1383 |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="timeSeriesCount" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
Head time series |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
461 |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="corruptionCount" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
WAL corruptions |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
0 |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="goroutineCount" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
Goroutines |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
37 |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="GOMAXPROCS" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
GOMAXPROCS |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
4 |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="GOGC" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
GOGC |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
/> |
||||
</tr> |
||||
<tr |
||||
key="GODEBUG" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
GODEBUG |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
/> |
||||
</tr> |
||||
<tr |
||||
key="storageRetention" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
Storage retention |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
15d |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</Table> |
||||
</Fragment>, |
||||
<Fragment |
||||
key="1" |
||||
> |
||||
<h2> |
||||
Build Information |
||||
</h2> |
||||
<Table |
||||
bordered={true} |
||||
className="h-auto" |
||||
responsiveTag="div" |
||||
size="sm" |
||||
striped={true} |
||||
tag="table" |
||||
> |
||||
<tbody> |
||||
<tr |
||||
key="version" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
version |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
/> |
||||
</tr> |
||||
<tr |
||||
key="revision" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
revision |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
/> |
||||
</tr> |
||||
<tr |
||||
key="branch" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
branch |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
/> |
||||
</tr> |
||||
<tr |
||||
key="buildUser" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
buildUser |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
/> |
||||
</tr> |
||||
<tr |
||||
key="buildDate" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
buildDate |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
/> |
||||
</tr> |
||||
<tr |
||||
key="goVersion" |
||||
> |
||||
<th |
||||
className="capitalize-title" |
||||
style={ |
||||
Object { |
||||
"width": "35%", |
||||
} |
||||
} |
||||
> |
||||
goVersion |
||||
</th> |
||||
<td |
||||
className="text-break" |
||||
> |
||||
go1.13.3 |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</Table> |
||||
</Fragment>, |
||||
<Fragment |
||||
key="2" |
||||
> |
||||
<h2> |
||||
Alertmanagers |
||||
</h2> |
||||
<Table |
||||
bordered={true} |
||||
className="h-auto" |
||||
responsiveTag="div" |
||||
size="sm" |
||||
striped={true} |
||||
tag="table" |
||||
> |
||||
<tbody> |
||||
<tr> |
||||
<th> |
||||
Endpoint |
||||
</th> |
||||
</tr> |
||||
<tr |
||||
key="https://1.2.3.4:9093/api/v1/alerts" |
||||
> |
||||
<td> |
||||
<a |
||||
href="https://1.2.3.4:9093/api/v1/alerts" |
||||
> |
||||
https://1.2.3.4:9093 |
||||
</a> |
||||
/api/v1/alerts |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="https://1.2.3.5:9093/api/v1/alerts" |
||||
> |
||||
<td> |
||||
<a |
||||
href="https://1.2.3.5:9093/api/v1/alerts" |
||||
> |
||||
https://1.2.3.5:9093 |
||||
</a> |
||||
/api/v1/alerts |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="https://1.2.3.6:9093/api/v1/alerts" |
||||
> |
||||
<td> |
||||
<a |
||||
href="https://1.2.3.6:9093/api/v1/alerts" |
||||
> |
||||
https://1.2.3.6:9093 |
||||
</a> |
||||
/api/v1/alerts |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="https://1.2.3.7:9093/api/v1/alerts" |
||||
> |
||||
<td> |
||||
<a |
||||
href="https://1.2.3.7:9093/api/v1/alerts" |
||||
> |
||||
https://1.2.3.7:9093 |
||||
</a> |
||||
/api/v1/alerts |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="https://1.2.3.8:9093/api/v1/alerts" |
||||
> |
||||
<td> |
||||
<a |
||||
href="https://1.2.3.8:9093/api/v1/alerts" |
||||
> |
||||
https://1.2.3.8:9093 |
||||
</a> |
||||
/api/v1/alerts |
||||
</td> |
||||
</tr> |
||||
<tr |
||||
key="https://1.2.3.9:9093/api/v1/alerts" |
||||
> |
||||
<td> |
||||
<a |
||||
href="https://1.2.3.9:9093/api/v1/alerts" |
||||
> |
||||
https://1.2.3.9:9093 |
||||
</a> |
||||
/api/v1/alerts |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</Table> |
||||
</Fragment>, |
||||
] |
||||
`; |
||||
Loading…
Reference in new issue