feat:新增实时日志组件 (#1492)

* feat:新增实时日志组件

* 修复可能串行的问题

* 增加图片动画示例

* typo

* 支持设置返回字符编码
This commit is contained in:
吴多益 2021-02-01 17:01:07 +08:00 committed by GitHub
parent 21b9a4a558
commit 26ef7d0819
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 429 additions and 2 deletions

View File

@ -0,0 +1,93 @@
---
title: Log 实时日志
description:
type: 0
group: ⚙ 组件
menuName: Log
icon:
order: 56
---
用于实时显示日志或程序输出结果。
## 基本用法
通过设置 source 来获取日志,支持 ANSI 基本颜色显示。
```json
{
"type": "log",
"height": 300,
"source": "http://localhost:3000/"
}
```
由于缺少线上服务,所以这个例子无法在线演,它的运行效果如下图所示
![示例](https://suda.cdn.bcebos.com/images%2Famis%2Flog.gif)
### 后端实现参考
后端需要通过流的方式输出结果,比如 Node 实现示例:
```javascript
const http = require('http');
let app = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'Access-Control-Allow-Origin': '*'
});
let index = 1;
let timer = setInterval(() => {
if (index < 50) {
// 每次 write 都会立刻传给前端
res.write(`line: ${index}\n`);
index += 1;
} else {
res.end('end');
clearInterval(timer);
}
}, 100);
});
app.listen(3000, '127.0.0.1');
console.log('Node server running on port 3000');
```
其它语言请查找如何使用 stream 的方式返回内容,比如 Spring 的 `StreamingResponseBody`
```java
@Controller
public class StreamingResponseBodyController {
@GetMapping("/logs")
public ResponseEntity<StreamingResponseBody> handleLog() {
StreamingResponseBody stream = out -> {
for (int i = 0; i < 1000; i++) {
String msg = "log" + " @ " + new Date();
out.write(msg.getBytes());
out.flush();
}
out.close();
};
return new ResponseEntity(stream, HttpStatus.OK);
}
}
```
需要注意有些反向代理有 buffer 设置,比如 nginx 的 [proxy_buffer_size](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffer_size),它会使得即便后端返回内容也需要等 buffer 满了才会真正返回前端,如果需要更实时的效果就需要关掉此功能。
## 自动滚动到底部
通过 `autoScroll` 可以关闭此功能
## 属性表
| 属性名 | 类型 | 默认值 | 说明 |
| ----------- | --------- | ------ | ------------------ |
| height | `number` | 500 | 展示区域高度 |
| className | `string` | | 外层 CSS 类名 |
| autoScroll | `boolean` | true | 是否自动滚动 |
| placeholder | `string` | | 加载中的文字 |
| encoding | `string` | utf-8 | 返回内容的字符编码 |

View File

@ -994,6 +994,15 @@ export default [
makeMarkdownRenderer
)
},
{
label: 'Log 实时日志',
path: '/zh-CN/docs/components/log',
getComponent: () =>
// @ts-ignore
import('../../docs/zh-CN/components/log.md').then(
makeMarkdownRenderer
)
},
{
label: 'Mapping 映射',
path: '/zh-CN/docs/components/mapping',

View File

@ -36,6 +36,7 @@
}
],
"dependencies": {
"ansi-to-react": "^6.1.4",
"async": "2.6.0",
"attr-accept": "2.2.2",
"blueimp-canvastoblob": "2.1.0",
@ -149,7 +150,7 @@
"jest-canvas-mock": "^2.3.0",
"js-yaml": "^3.10.0",
"lint-staged": "^8.1.6",
"marked": "^1.1.1",
"marked": "^1.2.8",
"mkdirp": "^1.0.4",
"mobx-wiretap": "^0.12.0",
"postcss": "^8.2.1",

View File

@ -3,7 +3,17 @@
var marked = require('marked');
let prism = require('prismjs');
let loadLanguages = require('prismjs/components/');
loadLanguages(['bash', 'javascript', 'jsx', 'tsx', 'css', 'markup', 'json']);
loadLanguages([
'bash',
'javascript',
'java',
'python',
'jsx',
'tsx',
'css',
'markup',
'json'
]);
var yaml = (yaml = require('js-yaml'));
var rYml = /^\s*---([\s\S]*?)---\s/;
var renderer = new marked.Renderer();

View File

@ -878,6 +878,12 @@
--ListMenu-item-color: var(--text-color);
--ListMenu-item-height: #{px2rem(34px)};
--Log-bg: #222;
--Log-padding: var(--gap-xs) 0;
--Log-line-padding: 0 var(--gap-xs);
--Log-color: #f1f1f1;
--Log-line--onHover-bg: #444;
--Modal-bg: var(--background);
--Modal-body--noHeader-paddingTop: var(--gap-base);
--Modal-body-borderBottom: var(--Modal-content-borderWidth) solid #{lighten(

109
scss/components/_log.scss Normal file
View File

@ -0,0 +1,109 @@
.#{$ns}Log {
background: var(--Log-bg);
color: var(--Log-color);
padding: var(--Log-padding);
overflow-y: auto;
font-family: var(--fontFamilyMonospace);
&-line {
padding: var(--Log-line-padding);
white-space: pre-wrap;
word-wrap: break-word;
// bootstrap 会修改这些覆盖掉
code {
color: inherit;
background: inherit;
}
&:hover {
background: var(--Log-line--onHover-bg);
}
}
.ansi-bold {
font-weight: 700;
}
.ansi-italic {
font-style: italic;
}
.ansi-underline {
text-decoration: underline;
}
.ansi-black-fg,
.ansi-bright-black-fg {
color: #4e4e4e;
}
.ansi-red-fg,
.ansi-bright-red-fg {
color: #ff6c60;
}
.ansi-green-fg,
.ansi-bright-green-fg {
color: #0a0;
}
.ansi-yellow-fg,
.ansi-bright-yellow-fg {
color: #ffffb6;
}
.ansi-blue-fg,
.ansi-bright-blue-fg {
color: #96cbfe;
}
.ansi-magenta-fg,
.ansi-bright-magenta-fg {
color: #ff73fd;
}
.ansi-cyan-fg,
.ansi-bright-cyan-fg {
color: #5ff;
}
.ansi-white-fg,
.ansi-bright-white-fg {
color: #eee;
}
.ansi-grey-fg,
.ansi-bright-grey-fg {
color: #969696;
}
.ansi-black-bg {
background-color: #4e4e4e;
}
.ansi-red-bg {
background-color: #ff6c60;
}
.ansi-green-bg {
background-color: #0a0;
}
.ansi-yellow-bg {
background-color: #ffffb6;
}
.ansi-blue-bg {
background-color: #96cbfe;
}
.ansi-magenta-bg {
background-color: #ff73fd;
}
.ansi-cyan-bg {
background-color: #0aa;
}
.ansi-white-bg {
background-color: #eee;
}
}

View File

@ -57,6 +57,7 @@
@import '../components/result-box';
@import '../components/search-box';
@import '../components/list-menu';
@import '../components/log';
@import '../components/json';
@import '../components/icon';

View File

@ -99,6 +99,7 @@ export type SchemaType =
| 'static-json' // 这个几个跟表单项同名再form下面用必须带前缀 static-
| 'link'
| 'list'
| 'log'
| 'static-list' // 这个几个跟表单项同名再form下面用必须带前缀 static-
| 'map'
| 'mapping'

View File

@ -127,6 +127,7 @@ import './renderers/VBox';
import './renderers/Image';
import './renderers/Images';
import './renderers/List';
import './renderers/Log';
import './renderers/Operation';
import './renderers/Page';
import './renderers/PaginationWrapper';

196
src/renderers/Log.tsx Normal file
View File

@ -0,0 +1,196 @@
/**
* @file
*/
import React from 'react';
import {Renderer, RendererProps} from '../factory';
import {BaseSchema, SchemaTpl} from '../Schema';
import Ansi from 'ansi-to-react';
import {filter} from '../utils/tpl';
/**
*
* https://baidu.gitee.io/amis/docs/components/log
*/
export interface LogSchema extends BaseSchema {
/**
* link
*/
type: 'log';
/**
* CSS
*/
className?: string;
/**
*
*/
source: string;
/**
*
*/
height?: number;
/**
*
*/
autoScroll?: boolean;
/**
*
*/
encoding?: string;
}
export interface LogProps extends RendererProps, LogSchema {}
export interface LogState {
lastLine: string;
logs: string[];
}
export class Log extends React.Component<LogProps, LogState> {
static defaultProps = {
height: 500,
autoScroll: true,
placeholder: 'loading',
encoding: 'utf-8'
};
isDone: boolean = false;
autoScroll: boolean = false;
logRef: React.RefObject<HTMLDivElement>;
state: LogState = {
lastLine: '',
logs: []
};
constructor(props: LogProps) {
super(props);
this.logRef = React.createRef();
this.autoScroll = props.autoScroll || false;
this.pauseOrResumeScrolling = this.pauseOrResumeScrolling.bind(this);
}
componentWillUnmount() {
if (this.logRef && this.logRef.current) {
this.logRef.current.removeEventListener(
'scroll',
this.pauseOrResumeScrolling
);
}
}
componentDidMount() {
if (this.autoScroll && this.logRef && this.logRef.current) {
this.logRef.current.addEventListener(
'scroll',
this.pauseOrResumeScrolling
);
}
this.loadLogs();
}
componentDidUpdate() {
if (this.autoScroll && this.logRef && this.logRef.current) {
this.logRef.current.scrollTop = this.logRef.current.scrollHeight;
}
}
// 如果向上滚动就停止自动滚动,除非滚到底部
pauseOrResumeScrolling() {
if (this.logRef && this.logRef.current) {
const {scrollHeight, scrollTop, offsetHeight} = this.logRef.current;
this.autoScroll = scrollHeight - (scrollTop + offsetHeight) < 50;
}
}
async loadLogs() {
const {source, env, translate: __, encoding} = this.props;
// 因为这里返回结果是流式的,和普通 api 请求不一样,如果直接用 fetcher 经过 responseAdpater 可能会导致出错,所以就直接 fetch 了
const res = await fetch(source);
if (res.status === 200) {
const body = res.body;
if (!body) {
return;
}
const reader = body.getReader();
let lastline = '';
let logs: string[] = [];
for (;;) {
let {done, value} = await reader.read();
if (value) {
let text = new TextDecoder(encoding).decode(value, {stream: true});
// 不考虑只有 \r 换行符的情况,几乎没人用
const lines = text.split('\n');
// 如果没有换行符就只更新最后一行
if (lines.length === 1) {
lastline += lines[0];
this.setState({
lastLine: lastline
});
} else {
// 将之前的数据补上
lines[0] = lastline + lines[0];
// 最后一个要么是空,要么是下一行的数据
lastline = lines.pop() || '';
logs = logs.concat(lines);
this.setState({
logs: logs,
lastLine: lastline
});
}
}
if (done) {
this.isDone = true;
return;
}
}
} else {
env.notify('error', __('fetchFailed'));
}
}
render() {
const {
className,
classnames: cx,
render,
placeholder,
height,
translate: __
} = this.props;
const lines = this.state.logs.map((line, index) => {
return (
<div className={cx('Log-line')} key={index}>
<Ansi useClasses>{line}</Ansi>
</div>
);
});
return (
<div
ref={this.logRef}
className={cx('Log', className)}
style={{height: height}}
>
{lines.length ? lines : __(placeholder)}
<div className={cx('Log-line')} key="last">
<code>{this.state.lastLine}</code>
</div>
</div>
);
}
}
@Renderer({
test: /(^|\/)log$/,
name: 'log'
})
export class LogRenderer extends Log {}