mirror of
https://gitee.com/baidu/amis.git
synced 2024-11-29 18:39:05 +08:00
feat:新增实时日志组件 (#1492)
* feat:新增实时日志组件 * 修复可能串行的问题 * 增加图片动画示例 * typo * 支持设置返回字符编码
This commit is contained in:
parent
21b9a4a558
commit
26ef7d0819
93
docs/zh-CN/components/log.md
Normal file
93
docs/zh-CN/components/log.md
Normal 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 | 返回内容的字符编码 |
|
@ -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',
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
|
@ -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
109
scss/components/_log.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
||||
|
@ -99,6 +99,7 @@ export type SchemaType =
|
||||
| 'static-json' // 这个几个跟表单项同名,再form下面用必须带前缀 static-
|
||||
| 'link'
|
||||
| 'list'
|
||||
| 'log'
|
||||
| 'static-list' // 这个几个跟表单项同名,再form下面用必须带前缀 static-
|
||||
| 'map'
|
||||
| 'mapping'
|
||||
|
@ -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
196
src/renderers/Log.tsx
Normal 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 {}
|
Loading…
Reference in New Issue
Block a user