github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/webapp/javascript/pages/ServiceDiscovery.tsx (about) 1 import React, { Children, useEffect, useState } from 'react'; 2 import { Target } from '@webapp/models/targets'; 3 import { useAppDispatch, useAppSelector } from '@webapp/redux/hooks'; 4 import { 5 loadTargets, 6 selectTargetsData, 7 } from '@webapp/redux/reducers/serviceDiscovery'; 8 import { formatDistance, parseISO } from 'date-fns'; 9 import cx from 'classnames'; 10 import Button from '@webapp/ui/Button'; 11 import styles from './ServiceDiscovery.module.scss'; 12 13 enum Status { 14 healthy = 'healthy', 15 info = 'info', 16 error = 'error', 17 } 18 19 const ServiceDiscoveryApp = () => { 20 const data = targetsToMap(useAppSelector(selectTargetsData)); 21 const dispatch = useAppDispatch(); 22 const [unavailableFilter, setUnavailableFilter] = useState(false); 23 const [expandAll, setExpandAll] = useState(true); 24 25 useEffect(() => { 26 async function run() { 27 await dispatch(loadTargets()); 28 } 29 30 run(); 31 }, []); 32 33 function getUpCount(targets: Target[]) { 34 return targets.filter((t) => t.health === 'up').length; 35 } 36 37 return ( 38 <div className={styles.serviceDiscoveryApp}> 39 <h2 className={styles.header}>Targets</h2> 40 <div className={styles.buttonGroup}> 41 <Button 42 kind="secondary" 43 grouped 44 onClick={() => setUnavailableFilter(!unavailableFilter)} 45 > 46 {unavailableFilter ? 'Show All' : 'Show Unhealthy Only'} 47 </Button> 48 <Button 49 kind="secondary" 50 grouped 51 onClick={() => setExpandAll(!expandAll)} 52 > 53 {expandAll ? 'Collapse All' : 'Expand All'} 54 </Button> 55 </div> 56 57 <div> 58 {Object.keys(data).length === 0 ? ( 59 <div> 60 {'No pull-mode targets configured. See '} 61 <a 62 className={styles.link} 63 href="https://pyroscope.io/docs/golang-pull-mode/" 64 target="_blank" 65 rel="noreferrer" 66 > 67 documentation 68 </a> 69 {' for information on how to add targets.'} 70 </div> 71 ) : ( 72 Object.keys(data).map((job) => { 73 const children = data[job].map((target) => { 74 const targetElem = ( 75 /* eslint-disable-next-line react/jsx-props-no-spreading */ 76 <TargetComponent {...target} key={target.url} /> 77 ); 78 if (unavailableFilter) { 79 if (target.health !== 'up') { 80 return targetElem; 81 } 82 return null; 83 } 84 return targetElem; 85 }); 86 87 return ( 88 <CollapsibleSection 89 title={`${data[job][0].job} (${getUpCount(data[job])}/${ 90 data[job].length 91 }) up`} 92 key={job} 93 open={expandAll} 94 > 95 {children} 96 </CollapsibleSection> 97 ); 98 }) 99 )} 100 </div> 101 </div> 102 ); 103 }; 104 105 const CollapsibleSection = ({ children, title, open }: ShamefulAny) => { 106 return Children.count(children.filter((c: ShamefulAny) => c)) > 0 ? ( 107 <details open={open}> 108 <summary className={styles.collapsibleHeader}>{title}</summary> 109 <div className={styles.collapsibleSection}> 110 <table className={styles.target}> 111 <thead> 112 <tr> 113 <th className={cx(styles.tableCell, styles.url)}>Scrape URL</th> 114 <th className={cx(styles.tableCell, styles.health)}>Health</th> 115 <th className={cx(styles.tableCell, styles.dicoveredLabels)}> 116 Discovered labels 117 </th> 118 <th className={cx(styles.tableCell, styles.labels)}>Labels</th> 119 <th className={cx(styles.tableCell, styles.lastScrape)}> 120 Last scrape 121 </th> 122 <th className={cx(styles.tableCell, styles.scrapeDuration)}> 123 Scrape duration 124 </th> 125 <th className={cx(styles.tableCell, styles.error)}>Last error</th> 126 </tr> 127 </thead> 128 <tbody>{children}</tbody> 129 </table> 130 </div> 131 </details> 132 ) : null; 133 }; 134 135 function formatDuration(input: string): string { 136 const a = input.match(/[a-zA-Z]+$/); 137 const b = a ? a[0] : ''; 138 return `${parseFloat(input).toFixed(2)} ${b}`; 139 } 140 141 const TargetComponent = ({ 142 discoveredLabels, 143 labels, 144 url, 145 lastError, 146 lastScrape, 147 lastScrapeDuration, 148 health, 149 }: Target) => { 150 return ( 151 <tr> 152 <td className={cx(styles.tableCell, styles.url)}>{url}</td> 153 <td className={cx(styles.tableCell, styles.health)}> 154 <Badge status={health === 'up' ? Status.healthy : Status.error}> 155 {health} 156 </Badge> 157 </td> 158 <td className={cx(styles.tableCell, styles.dicoveredLabels)}> 159 {Object.keys(discoveredLabels).map((key) => ( 160 <Badge 161 status={Status.info} 162 key={key} 163 >{`${key}=${discoveredLabels[key]}`}</Badge> 164 ))} 165 </td> 166 <td className={cx(styles.tableCell, styles.labels)}> 167 {Object.keys(labels).map((key) => ( 168 <Badge 169 status={Status.info} 170 key={key} 171 >{`${key}=${labels[key]}`}</Badge> 172 ))} 173 </td> 174 <td 175 className={cx(styles.tableCell, styles.lastScrape)} 176 title={lastScrape} 177 > 178 {formatDistance(parseISO(lastScrape), new Date())} ago 179 </td> 180 <td className={cx(styles.tableCell, styles.scrapeDuration)}> 181 {formatDuration(lastScrapeDuration)} 182 </td> 183 <td className={cx(styles.tableCell, styles.error)}>{lastError || '-'}</td> 184 </tr> 185 ); 186 }; 187 188 const Badge = ({ children, status }: { children: string; status: Status }) => { 189 function getStatusClass(status: ShamefulAny) { 190 switch (status) { 191 case Status.healthy: 192 return styles.healthy; 193 case Status.info: 194 return styles.info; 195 case Status.error: 196 return styles.error; 197 default: 198 return styles.info; 199 } 200 } 201 return ( 202 <span className={cx(styles.badge, getStatusClass(status))}>{children}</span> 203 ); 204 }; 205 206 type TargetRecord = Record<string, Target[]>; 207 const targetsToMap: (state: Target[]) => TargetRecord = (state) => { 208 const acc = state.reduce((acc: TargetRecord, next: Target) => { 209 if (!acc[next.job]) { 210 acc[next.job] = []; 211 } 212 acc[next.job].push(next); 213 return acc; 214 }, {} as TargetRecord); 215 return acc; 216 }; 217 218 export default ServiceDiscoveryApp;