Logs.jsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. // @flow
  2. // Copyright 2018 The go-ethereum Authors
  3. // This file is part of the go-ethereum library.
  4. //
  5. // The go-ethereum library is free software: you can redistribute it and/or modify
  6. // it under the terms of the GNU Lesser General Public License as published by
  7. // the Free Software Foundation, either version 3 of the License, or
  8. // (at your option) any later version.
  9. //
  10. // The go-ethereum library is distributed in the hope that it will be useful,
  11. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. // GNU Lesser General Public License for more details.
  14. //
  15. // You should have received a copy of the GNU Lesser General Public License
  16. // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
  17. import React, {Component} from 'react';
  18. import List, {ListItem} from 'material-ui/List';
  19. import escapeHtml from 'escape-html';
  20. import type {Record, Content, LogsMessage, Logs as LogsType} from '../types/content';
  21. // requestBand says how wide is the top/bottom zone, eg. 0.1 means 10% of the container height.
  22. const requestBand = 0.05;
  23. // fieldPadding is a global map with maximum field value lengths seen until now
  24. // to allow padding log contexts in a bit smarter way.
  25. const fieldPadding = new Map();
  26. // createChunk creates an HTML formatted object, which displays the given array similarly to
  27. // the server side terminal.
  28. const createChunk = (records: Array<Record>) => {
  29. let content = '';
  30. records.forEach((record) => {
  31. const {t, ctx} = record;
  32. let {lvl, msg} = record;
  33. let color = '#ce3c23';
  34. switch (lvl) {
  35. case 'trace':
  36. case 'trce':
  37. lvl = 'TRACE';
  38. color = '#3465a4';
  39. break;
  40. case 'debug':
  41. case 'dbug':
  42. lvl = 'DEBUG';
  43. color = '#3d989b';
  44. break;
  45. case 'info':
  46. lvl = 'INFO&nbsp;';
  47. color = '#4c8f0f';
  48. break;
  49. case 'warn':
  50. lvl = 'WARN&nbsp;';
  51. color = '#b79a22';
  52. break;
  53. case 'error':
  54. case 'eror':
  55. lvl = 'ERROR';
  56. color = '#754b70';
  57. break;
  58. case 'crit':
  59. lvl = 'CRIT&nbsp;';
  60. color = '#ce3c23';
  61. break;
  62. default:
  63. lvl = '';
  64. }
  65. const time = new Date(t);
  66. if (lvl === '' || !(time instanceof Date) || isNaN(time) || typeof msg !== 'string' || !Array.isArray(ctx)) {
  67. content += '<span style="color:#ce3c23">Invalid log record</span><br />';
  68. return;
  69. }
  70. if (ctx.length > 0) {
  71. msg += '&nbsp;'.repeat(Math.max(40 - msg.length, 0));
  72. }
  73. const month = `0${time.getMonth() + 1}`.slice(-2);
  74. const date = `0${time.getDate()}`.slice(-2);
  75. const hours = `0${time.getHours()}`.slice(-2);
  76. const minutes = `0${time.getMinutes()}`.slice(-2);
  77. const seconds = `0${time.getSeconds()}`.slice(-2);
  78. content += `<span style="color:${color}">${lvl}</span>[${month}-${date}|${hours}:${minutes}:${seconds}] ${msg}`;
  79. for (let i = 0; i < ctx.length; i += 2) {
  80. const key = escapeHtml(ctx[i]);
  81. const val = escapeHtml(ctx[i + 1]);
  82. let padding = fieldPadding.get(key);
  83. if (typeof padding !== 'number' || padding < val.length) {
  84. padding = val.length;
  85. fieldPadding.set(key, padding);
  86. }
  87. let p = '';
  88. if (i < ctx.length - 2) {
  89. p = '&nbsp;'.repeat(padding - val.length);
  90. }
  91. content += ` <span style="color:${color}">${key}</span>=${val}${p}`;
  92. }
  93. content += '<br />';
  94. });
  95. return content;
  96. };
  97. // ADDED, SAME and REMOVED are used to track the change of the log chunk array.
  98. // The scroll position is set using these values.
  99. const ADDED = 1;
  100. const SAME = 0;
  101. const REMOVED = -1;
  102. // inserter is a state updater function for the main component, which inserts the new log chunk into the chunk array.
  103. // limit is the maximum length of the chunk array, used in order to prevent the browser from OOM.
  104. export const inserter = (limit: number) => (update: LogsMessage, prev: LogsType) => {
  105. prev.topChanged = SAME;
  106. prev.bottomChanged = SAME;
  107. if (!Array.isArray(update.chunk) || update.chunk.length < 1) {
  108. return prev;
  109. }
  110. if (!Array.isArray(prev.chunks)) {
  111. prev.chunks = [];
  112. }
  113. const content = createChunk(update.chunk);
  114. if (!update.source) {
  115. // In case of stream chunk.
  116. if (!prev.endBottom) {
  117. return prev;
  118. }
  119. if (prev.chunks.length < 1) {
  120. // This should never happen, because the first chunk is always a non-stream chunk.
  121. return [{content, name: '00000000000000.log'}];
  122. }
  123. prev.chunks[prev.chunks.length - 1].content += content;
  124. prev.bottomChanged = ADDED;
  125. return prev;
  126. }
  127. const chunk = {
  128. content,
  129. name: update.source.name,
  130. };
  131. if (prev.chunks.length > 0 && update.source.name < prev.chunks[0].name) {
  132. if (update.source.last) {
  133. prev.endTop = true;
  134. }
  135. if (prev.chunks.length >= limit) {
  136. prev.endBottom = false;
  137. prev.chunks.splice(limit - 1, prev.chunks.length - limit + 1);
  138. prev.bottomChanged = REMOVED;
  139. }
  140. prev.chunks = [chunk, ...prev.chunks];
  141. prev.topChanged = ADDED;
  142. return prev;
  143. }
  144. if (update.source.last) {
  145. prev.endBottom = true;
  146. }
  147. if (prev.chunks.length >= limit) {
  148. prev.endTop = false;
  149. prev.chunks.splice(0, prev.chunks.length - limit + 1);
  150. prev.topChanged = REMOVED;
  151. }
  152. prev.chunks = [...prev.chunks, chunk];
  153. prev.bottomChanged = ADDED;
  154. return prev;
  155. };
  156. // styles contains the constant styles of the component.
  157. const styles = {
  158. logListItem: {
  159. padding: 0,
  160. lineHeight: 1.231,
  161. },
  162. logChunk: {
  163. color: 'white',
  164. fontFamily: 'monospace',
  165. whiteSpace: 'nowrap',
  166. width: 0,
  167. },
  168. waitMsg: {
  169. textAlign: 'center',
  170. color: 'white',
  171. fontFamily: 'monospace',
  172. },
  173. };
  174. export type Props = {
  175. container: Object,
  176. content: Content,
  177. shouldUpdate: Object,
  178. send: string => void,
  179. };
  180. type State = {
  181. requestAllowed: boolean,
  182. };
  183. // Logs renders the log page.
  184. class Logs extends Component<Props, State> {
  185. constructor(props: Props) {
  186. super(props);
  187. this.content = React.createRef();
  188. this.state = {
  189. requestAllowed: true,
  190. };
  191. }
  192. componentDidMount() {
  193. const {container} = this.props;
  194. if (typeof container === 'undefined') {
  195. return;
  196. }
  197. container.scrollTop = container.scrollHeight - container.clientHeight;
  198. const {logs} = this.props.content;
  199. if (typeof this.content === 'undefined' || logs.chunks.length < 1) {
  200. return;
  201. }
  202. if (this.content.clientHeight < container.clientHeight && !logs.endTop) {
  203. this.sendRequest(logs.chunks[0].name, true);
  204. }
  205. }
  206. // onScroll is triggered by the parent component's scroll event, and sends requests if the scroll position is
  207. // at the top or at the bottom.
  208. onScroll = () => {
  209. if (!this.state.requestAllowed || typeof this.content === 'undefined') {
  210. return;
  211. }
  212. const {logs} = this.props.content;
  213. if (logs.chunks.length < 1) {
  214. return;
  215. }
  216. if (this.atTop() && !logs.endTop) {
  217. this.sendRequest(logs.chunks[0].name, true);
  218. } else if (this.atBottom() && !logs.endBottom) {
  219. this.sendRequest(logs.chunks[logs.chunks.length - 1].name, false);
  220. }
  221. };
  222. sendRequest = (name: string, past: boolean) => {
  223. this.setState({requestAllowed: false});
  224. this.props.send(JSON.stringify({
  225. Logs: {
  226. Name: name,
  227. Past: past,
  228. },
  229. }));
  230. };
  231. // atTop checks if the scroll position it at the top of the container.
  232. atTop = () => this.props.container.scrollTop <= this.props.container.scrollHeight * requestBand;
  233. // atBottom checks if the scroll position it at the bottom of the container.
  234. atBottom = () => {
  235. const {container} = this.props;
  236. return container.scrollHeight - container.scrollTop <=
  237. container.clientHeight + container.scrollHeight * requestBand;
  238. };
  239. // beforeUpdate is called by the parent component, saves the previous scroll position
  240. // and the height of the first log chunk, which can be deleted during the insertion.
  241. beforeUpdate = () => {
  242. let firstHeight = 0;
  243. let chunkList = this.content.children[1];
  244. if (chunkList && chunkList.children[0]) {
  245. firstHeight = chunkList.children[0].clientHeight;
  246. }
  247. return {
  248. scrollTop: this.props.container.scrollTop,
  249. firstHeight,
  250. };
  251. };
  252. // didUpdate is called by the parent component, which provides the container. Sends the first request if the
  253. // visible part of the container isn't full, and resets the scroll position in order to avoid jumping when a
  254. // chunk is inserted or removed.
  255. didUpdate = (prevProps, prevState, snapshot) => {
  256. if (typeof this.props.shouldUpdate.logs === 'undefined' || typeof this.content === 'undefined' || snapshot === null) {
  257. return;
  258. }
  259. const {logs} = this.props.content;
  260. const {container} = this.props;
  261. if (typeof container === 'undefined' || logs.chunks.length < 1) {
  262. return;
  263. }
  264. if (this.content.clientHeight < container.clientHeight) {
  265. // Only enters here at the beginning, when there aren't enough logs to fill the container
  266. // and the scroll bar doesn't appear.
  267. if (!logs.endTop) {
  268. this.sendRequest(logs.chunks[0].name, true);
  269. }
  270. return;
  271. }
  272. let {scrollTop} = snapshot;
  273. if (logs.topChanged === ADDED) {
  274. // It would be safer to use a ref to the list, but ref doesn't work well with HOCs.
  275. scrollTop += this.content.children[1].children[0].clientHeight;
  276. } else if (logs.bottomChanged === ADDED) {
  277. if (logs.topChanged === REMOVED) {
  278. scrollTop -= snapshot.firstHeight;
  279. } else if (this.atBottom() && logs.endBottom) {
  280. scrollTop = container.scrollHeight - container.clientHeight;
  281. }
  282. }
  283. container.scrollTop = scrollTop;
  284. this.setState({requestAllowed: true});
  285. };
  286. render() {
  287. return (
  288. <div ref={(ref) => { this.content = ref; }}>
  289. <div style={styles.waitMsg}>
  290. {this.props.content.logs.endTop ? 'No more logs.' : 'Waiting for server...'}
  291. </div>
  292. <List>
  293. {this.props.content.logs.chunks.map((c, index) => (
  294. <ListItem style={styles.logListItem} key={index}>
  295. <div style={styles.logChunk} dangerouslySetInnerHTML={{__html: c.content}} />
  296. </ListItem>
  297. ))}
  298. </List>
  299. {this.props.content.logs.endBottom || <div style={styles.waitMsg}>Waiting for server...</div>}
  300. </div>
  301. );
  302. }
  303. }
  304. export default Logs;