发布于 

java,通过接口读取日志文件

前言

nginx需要配置对sse的支持,否则访问不通

修改nginx配置

1
vim /etc/nginx/sites-available/default
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
server {
listen 443 ssl;
server_name 域名.后缀;
client_max_body_size 30M;

ssl_certificate /etc/letsencrypt/live/域名.后缀/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/域名.后缀/privkey.pem; # managed by Certbot

# 默认的 location 配置,不影响其他请求
location / {
proxy_pass http://127.0.0.1:6001; # 代理到本地服务
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

# 针对 SSE 请求的特殊配置
location /service/logs/realtime { # 假设 SSE 请求是通过 /service/logs/realtime 路径处理的
proxy_pass http://127.0.0.1:6001; # 代理到本地服务
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# SSE 关键优化
proxy_buffering off; # 禁用缓冲,确保数据流不中断
proxy_cache off; # 确保 Nginx 不缓存 SSE 响应
proxy_set_header Connection ''; # 避免 Nginx 添加 `Connection: close`,保持连接
chunked_transfer_encoding off; # 禁用 chunked 传输,SSE 不需要它

# 只针对 SSE 请求设置的超时
proxy_read_timeout 3600s; # 设置为较长时间(例如 3600 秒 = 1 小时)
proxy_send_timeout 3600s; # 设置为较长时间
send_timeout 3600s; # 设置 Nginx 向客户端发送响应的超时时间
}
}

检查nginx配置

1
sudo nginx -t
1
2
3
root@touchs-test:/home/kevin# sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

重新加载nginx配置

不会中断现有连接

1
nginx -s reload

服务端

配置文件

yml

1
2
3
server-log:
enabled: true # 设置为true开启日志读取功能
filePath: /Users/edy/Documents/console.log # 日志文件路径

LogReaderProperties

1
2
3
4
5
6
7
8
9
10
11
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;

@Data
@Component
@ConfigurationProperties(prefix = "server-log")
public class LogReaderProperties {
private String filePath;
private boolean enabled = false;
}

方式一(不建议)

这种处理可能会抛异常:User limit of inotify instances reached or too many open files

结构关系:

1
2
3
4
controller
-tools
-LogReaderProperties
-SystemLogController

SystemLogController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
package com.touchsmail.controller.tools.log;

import com.touchsmail.util.StringUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.time.*;
import java.time.format.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;

@RestController
@RequestMapping("/service/logs")
public class SystemLogController {

// private static final String LOG_FILE_PATH = "/Users/edy/Documents/console.log";
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

private final ExecutorService executor = Executors.newCachedThreadPool();

@Resource
private LogReaderProperties properties;

// 增加响应头,处理中文乱码
private HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_PLAIN);
headers.set(HttpHeaders.CONTENT_ENCODING, "UTF-8");
headers.set(HttpHeaders.CONTENT_TYPE, "text/plain;charset=UTF-8");
return headers;
}


private void checkEnabled() {
if (!properties.isEnabled()) {
throw new IllegalStateException("log not open!");
}
}

private void checkFileExists() {
if (StringUtils.isBlank(properties.getFilePath()) || !Files.exists(Paths.get(properties.getFilePath()))) {
throw new IllegalStateException("log file does not exist!");
}
}

@GetMapping("/")
public ResponseEntity<String> explain() {
checkEnabled();
checkFileExists();
final String content = "// 读取全部日志\n" +
"GET http://localhost:6001/service/logs/all\n" +
"\n" +
"// 读取最新10条日志\n" +
"GET http://localhost:6001/service/logs/latest?lines=10\n" +
"\n" +
"// 读取指定时间范围的日志\n" +
"GET http://localhost:6001/service/logs/time?startTime=2024-12-01 00:00:00&endTime=2024-12-31 00:00:00\n" +
"\n" +
"// 流式读取日志\n" +
"GET http://localhost:6001/service/logs/stream?bufferSize=100"+
"// 流式追踪最新日志\n" +
"GET http://localhost:6001/service/logs/realtime";
return new ResponseEntity<>(content, getHeaders(), HttpStatus.OK);
}

@GetMapping("/all")
public ResponseEntity<String> readAllLogs() {
checkEnabled();
checkFileExists();
try {
String content = new String(Files.readAllBytes(Paths.get(properties.getFilePath())), StandardCharsets.UTF_8);
return new ResponseEntity<>(content, getHeaders(), HttpStatus.OK);
} catch (IOException e) {
return new ResponseEntity<>("读取日志文件失败: " + e.getMessage(), getHeaders(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}

@GetMapping("/latest")
public ResponseEntity<String> readLatestLogs(@RequestParam(defaultValue = "10") int lines) {
try {
checkEnabled();
checkFileExists();
List<String> allLines = Files.readAllLines(Paths.get(properties.getFilePath()), StandardCharsets.UTF_8);
int startIndex = Math.max(0, allLines.size() - lines);
List<String> latestLines = allLines.subList(startIndex, allLines.size());

StringBuilder result = new StringBuilder();
for (String line : latestLines) {
result.append(line).append("\n");
}

return new ResponseEntity<>(result.toString(), getHeaders(), HttpStatus.OK);
} catch (IOException e) {
return new ResponseEntity<>("读取日志文件失败: " + e.getMessage(), getHeaders(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}

@GetMapping("/time")
public ResponseEntity<String> readLogsByTimeRange(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
try {
checkEnabled();
checkFileExists();
List<String> allLines = Files.readAllLines(Paths.get(properties.getFilePath()), StandardCharsets.UTF_8);
StringBuilder result = new StringBuilder();

for (String line : allLines) {
try {
String timeStr = line.substring(0, 19);
LocalDateTime logTime = LocalDateTime.parse(timeStr, DATE_FORMAT);

if ((logTime.isEqual(startTime) || logTime.isAfter(startTime)) &&
(logTime.isEqual(endTime) || logTime.isBefore(endTime))) {
result.append(line).append("\n");
}
} catch (Exception e) {
// Skip malformed log entries
continue;
}
}

return new ResponseEntity<>(result.toString(), getHeaders(), HttpStatus.OK);
} catch (IOException e) {
return new ResponseEntity<>("读取日志文件失败: " + e.getMessage(), getHeaders(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}

@GetMapping("/stream")
public ResponseEntity<String> streamLog(@RequestParam(defaultValue = "100") int maxLines) {
try {
checkEnabled();
checkFileExists();
List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream(properties.getFilePath()), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
if (lines.size() > maxLines) {
lines.remove(0);
}
}
}

StringBuilder result = new StringBuilder();
for (String line : lines) {
result.append(line).append("\n");
}

return new ResponseEntity<>(result.toString(), getHeaders(), HttpStatus.OK);
} catch (IOException e) {
return new ResponseEntity<>("读取日志文件失败: " + e.getMessage(), getHeaders(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}

/**
* 实时读取日志文件内容并推送给客户端
*
* @param initialLines 初次读取日志的行数(可选,默认为 0,不读取初始内容,直接开始实时监控和推送新增日志)
* @param filePath 日志文件路径,不传就用配置文件中的
* @return SseEmitter 用于推送实时日志内容
*/
@GetMapping("/realtime")
public SseEmitter streamLogRealtime(@RequestParam(defaultValue = "0") int initialLines, @RequestParam(required = false) String filePath) {
// checkEnabled();

SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
Path logFilePath = (filePath != null && !filePath.isEmpty()) ? Paths.get(filePath) : Paths.get(properties.getFilePath());

if (!Files.exists(logFilePath)) {
throw new RuntimeException("Log file not found: " + filePath);
}

executor.execute(() -> {
try {
WatchService watchService = FileSystems.getDefault().newWatchService();
logFilePath.getParent().register(watchService, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);

AtomicLong filePointer = new AtomicLong(Files.size(logFilePath));

// 读取最后 N 行日志
if (initialLines > 0) {
List<String> lastLines = readLastLines(logFilePath, initialLines);
for (String line : lastLines) {
emitter.send(SseEmitter.event().data(line));
}
}

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
try {
long newSize = Files.size(logFilePath);
if (newSize > filePointer.get()) {
// 使用 UTF-8 读取文件内容,避免乱码
try (RandomAccessFile file = new RandomAccessFile(logFilePath.toFile(), "r");
InputStreamReader isr = new InputStreamReader(new FileInputStream(file.getFD()), StandardCharsets.UTF_8);
BufferedReader reader = new BufferedReader(isr)) {

file.seek(filePointer.get()); // 移动到上次读取的位置
String line;
while ((line = reader.readLine()) != null) {
emitter.send(SseEmitter.event().data(line)); // 发送 UTF-8 正确编码的日志
}
filePointer.set(file.getFilePointer()); // 更新文件指针
}
}
} catch (IOException e) {
emitter.completeWithError(e);
}
}, 0, 1, TimeUnit.SECONDS);

while (true) {
WatchKey key = watchService.take();
boolean fileDeleted = false;

for (WatchEvent<?> event : key.pollEvents()) {
Path changed = (Path) event.context();
if (changed.endsWith(logFilePath.getFileName())) {
if (event.kind() == StandardWatchEventKinds.ENTRY_DELETE) {
fileDeleted = true;
}
}
}

key.reset();

if (fileDeleted) {
emitter.send(SseEmitter.event().data("Log file deleted. Stopping log streaming."));
emitter.complete();
scheduler.shutdown();
break;
}
}
} catch (IOException | InterruptedException e) {
emitter.completeWithError(e);
}
});

return emitter;
}


/**
* 读取文件的最后 n 行
*
* @param filePath 文件路径
* @param lines 需要读取的行数
* @return 最后 n 行的内容列表
*/
private List<String> readLastLines(Path filePath, int lines) throws IOException {
List<String> result = new ArrayList<>();
// 使用 BufferedReader 以 UTF-8 编码读取文件
try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8)) {
// 获取文件的所有行
List<String> allLines = Files.readAllLines(filePath, StandardCharsets.UTF_8);

// 从末尾读取最后 lines 行
int start = Math.max(allLines.size() - lines, 0);
for (int i = start; i < allLines.size(); i++) {
result.add(allLines.get(i));
}
}
return result;
}



/**
* 在类销毁时关闭线程池
*/
@PreDestroy
public void shutdownExecutor() {
executor.shutdown();
}

}

调用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

//调用说明
GET http://localhost:8080/service/logs/

// 读取全部日志
GET http://localhost:8080/service/logs/all

// 读取最新10条日志
GET http://localhost:8080/service/logs/latest?lines=10

// 读取指定时间范围的日志
GET http://localhost:8080/service/logs/time?startTime=2024-12-01 00:00:00&endTime=2024-12-30 00:00:00

// 流式读取日志
GET http://localhost:8080/service/logs/stream?bufferSize=100

// 流式追踪最新日志
GET [http://localhost:8080/service/logs/stream?bufferSize=100](http://localhost:6001/service/logs/realtime?
initialLines={{$random.integer(100)}}&
filePath={{$random.alphanumeric(8)}})

方式二(推荐)

结构关系:

1
2
3
4
5
controller
-tools
-LogReaderProperties
-LogStreamingController
-LogStreamingService

LogStreamingController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.touchsmail.controller.tools.log;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;


@RestController
@RequestMapping("/service/logs")
public class LogStreamingController {

private final LogStreamingService logStreamingService;

public LogStreamingController(LogStreamingService logStreamingService) {
this.logStreamingService = logStreamingService;
}

@GetMapping("/realtime")
public SseEmitter streamLogRealtime(@RequestParam(defaultValue = "0") int initialLines,
@RequestParam(required = false) String filePath) {
return logStreamingService.streamLogRealtime(initialLines, filePath);
}
}

LogStreamingService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
package com.touchsmail.controller.tools.log;

import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import javax.annotation.Resource;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

@Service
public class LogStreamingService {

private final ExecutorService executor = Executors.newCachedThreadPool();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();
private final WatchService watchService;
private final ConcurrentHashMap<Path, AtomicLong> filePointers = new ConcurrentHashMap<>();

@Resource
private LogReaderProperties properties;

public LogStreamingService() throws IOException {
this.watchService = FileSystems.getDefault().newWatchService();
startFileWatcher();
}

public SseEmitter streamLogRealtime(int initialLines, String filePath) {

if (!properties.isEnabled()) {
throw new IllegalStateException("log not open!");
}

Path logFilePath = (filePath != null && !filePath.isEmpty()) ? Paths.get(filePath) : Paths.get(properties.getFilePath());

if (!Files.exists(logFilePath)) {
throw new RuntimeException("Log file not found: " + logFilePath);
}

SseEmitter emitter = new SseEmitter(0L);
emitters.add(emitter);

executor.execute(() -> sendInitialLines(emitter, logFilePath, initialLines));

AtomicLong filePointer = filePointers.computeIfAbsent(logFilePath, path -> new AtomicLong(getFileSize(path)));

scheduler.scheduleAtFixedRate(() -> {
try {
readNewLines(emitter, logFilePath, filePointer);
} catch (IOException e) {
emitter.completeWithError(e);
}
}, 0, 1, TimeUnit.SECONDS);

// Schedule a heart beat every 30 seconds
scheduler.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event().data("heartbeat"));
} catch (IOException e) {
emitter.completeWithError(e);
}
}, 0, 30, TimeUnit.SECONDS);

emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onTimeout(() -> emitters.remove(emitter));

return emitter;
}

private void sendInitialLines(SseEmitter emitter, Path logFilePath, int initialLines) {
try {
List<String> lastLines = readLastLines(logFilePath, initialLines);
for (String line : lastLines) {
emitter.send(SseEmitter.event().data(line));
}
} catch (IOException e) {
emitter.completeWithError(e);
}
}

private void readNewLines(SseEmitter emitter, Path logFilePath, AtomicLong filePointer) throws IOException {
long newSize = Files.size(logFilePath);
if (newSize > filePointer.get()) {
try (RandomAccessFile file = new RandomAccessFile(logFilePath.toFile(), "r");
InputStreamReader isr = new InputStreamReader(new FileInputStream(file.getFD()), StandardCharsets.UTF_8);
BufferedReader reader = new BufferedReader(isr)) {

file.seek(filePointer.get());
String line;
while ((line = reader.readLine()) != null) {
emitter.send(SseEmitter.event().data(line));
}
filePointer.set(file.getFilePointer());
}
}
}

private List<String> readLastLines(Path path, int n) throws IOException {
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
return lines.subList(Math.max(lines.size() - n, 0), lines.size());
}

private long getFileSize(Path path) {
try {
return Files.exists(path) ? Files.size(path) : 0;
} catch (IOException e) {
return 0;
}
}

private void startFileWatcher() {
executor.execute(() -> {
while (true) {
try {
WatchKey key = watchService.take();
for (WatchEvent<?> event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.ENTRY_DELETE) {
Path deletedFile = (Path) event.context();
emitters.forEach(emitter -> {
try {
emitter.send(SseEmitter.event().data("Log file deleted: " + deletedFile));
emitter.complete();
} catch (IOException ignored) {
}
});
emitters.clear();
}
}
key.reset();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
}
}

前端

结构

1
2
3
4
5
6
7
src
-views
-serviceTable
-serviceTable.less
-serviceTable.tsx
-EventBus.ts
-useEventBus.tsx

前端代码不全,仅供参考,缺少全局状态路由通知

EventBus.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type Listener = (...args: any[]) => void;

class EventBus {
private events: { [event: string]: Listener[] };

constructor() {
this.events = {};
}

// 注册事件监听器
on(event: string, listener: Listener): void {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
}

// 移除事件监听器
off(event: string, listener: Listener): void {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter((l) => l !== listener);
}

// 触发事件
emit(event: string, ...args: any[]): void {
if (!this.events[event]) return;
this.events[event].forEach((listener) => listener(...args));
}
}

export const eventBus = new EventBus();

useEventBus.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { useEffect } from 'react';
import {eventBus} from './EventBus'; // 注意:不需要加 .tsx 后缀

// 定义事件回调函数的类型
type EventCallback = (data: any) => void;

// 自定义 Hook: 用于订阅和发布事件
export const useEventBus = (event: string, callback: EventCallback): void => {
useEffect(() => {
// 订阅事件
eventBus.on(event, callback);

// 组件卸载时移除事件监听器
return () => {
eventBus.off(event, callback);
};
}, [event, callback]); // 依赖项:事件和回调函数
};

// 发布事件的函数
export const publishEvent = (event: string, data: any): void => {
eventBus.emit(event, data);
};

serviceTable.less

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
.msgBox {
width: 100%;
height: calc(100vh - 150px);
overflow: auto;
background-color: #1e1e1e; /* VS Code 深色背景 */
color: #d4d4d4; /* VS Code 默认字体颜色 */
padding: 20px 10px;
list-style-type: none;
font-family: monospace;
font-size: 14px;
line-height: 1.4;
transition: all 0.3s ease;
}

.msgBox.fullscreen {
height: 100vh; /* 全屏时撑满视口 */
padding: 24px 16px; /* 适当调整内边距 */
font-size: 16px; /* 放大字体方便阅读 */
line-height: 1.5;
}

.msgBox li {
margin-bottom: 10px;
font-size: 18px;
line-height: 26px;
}

.msgBox li .li-item {
white-space: pre-wrap; /* 自动换行,保留空格 */
word-break: break-word; /* 单词内断行 */
// padding-left: 1em; /* 缩进效果 */
padding-top: 2px;
padding-bottom: 2px;
text-indent: 0;
}

.log-pre {
margin: 0;
white-space: pre-wrap;
font-family: monospace;
font-size: 14px;
line-height: 1.3;
color: #d4d4d4;
}

serviceTable.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
import React, { useEffect, useState, useRef } from "react";
import { Button, Input, InputNumber, Select } from "antd";
import screenfull from "screenfull";
import { useTranslation } from "react-i18next";
import { useEventBus } from "@/useEventBus"; // 你自己的事件总线钩子
import "./serviceTable.less";

const baseURL = import.meta.env.VITE_API_URL;

const predefinedPaths = [
{ value: "/data/apps/ai/logs/run.log", name: "AI日志" },
];

const ServiceTable: React.FC = () => {
const { t } = useTranslation();

const divRef = useRef<HTMLUListElement | null>(null);

const [filePath, setFilePath] = useState<string>("");
const [logs, setLogs] = useState<string[][]>([]);
const [initialLines, setInitialLines] = useState<number>(100);
const [btnLoading, setBtnLoading] = useState<boolean>(false);
const [isConnect, setIsConnect] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);

const token = localStorage.getItem("admin_token") || "";

// SSE 控制相关 refs 和计数器
const abortControllerRef = useRef<AbortController | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const retryCountRef = useRef<number>(0);
const maxRetries = 3;

// 事件总线监听示例,收到特定事件时清理状态
useEventBus("message", (data: any) => {
if (data && data !== "log/serviceTable") {
reset();
setLogs([]);
setInitialLines(100);
setFilePath("");
}
});

// 全屏状态监听
useEffect(() => {
if (!screenfull.isEnabled) return;

const onChange = () => {
setIsFullscreen(screenfull.isFullscreen);
};

screenfull.on("change", onChange);

return () => {
screenfull.off("change", onChange);
};
}, []);

// 组件卸载时清理
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
setIsConnect(false);
setLogs([]);
setFilePath("");
setInitialLines(100);
};
}, []);

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
setLogs((prev) => [...prev, ["\n"]]); // 插入空行
setTimeout(() => {
if (divRef.current) {
divRef.current.scrollTop = divRef.current.scrollHeight;
}
}, 100);
}
};

window.addEventListener("keydown", handleKeyDown);

return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);

// 连接 SSE
const connectToSSE = async () => {
// 中止旧请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}

abortControllerRef.current = new AbortController();

const url = `${baseURL}/service/logs/realtime?filePath=${encodeURIComponent(
filePath
)}&initialLines=${initialLines}`;

try {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: token,
"Content-Type": "application/json",
},
signal: abortControllerRef.current.signal,
});

setBtnLoading(false);

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

if (!response.body) {
throw new Error("Response body is not a readable stream");
}

setIsConnect(true);
retryCountRef.current = 0; // 重连计数清零

const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let done = false;

while (!done) {
const result = await reader.read();
done = result.done ?? false;
if (done) {
console.log("SSE stream closed");
attemptReconnect();
break;
}

const chunk = decoder.decode(result.value, { stream: true });
const messages = formatLog(chunk);
setLogs((prev) => [...prev, messages]);

setTimeout(() => {
if (divRef.current) {
divRef.current.scrollTop = divRef.current.scrollHeight;
}
}, 100);
}
} catch (error: any) {
setBtnLoading(false);
if (error.name === "AbortError") {
console.log("SSE request was aborted");
} else {
console.error("SSE error:", error);
attemptReconnect();
}
}
};

// 格式化日志字符串为字符串数组
const formatLog = (logString: string): string[] => {
const cleaned = logString
.split("data:")
.map(
(entry) =>
entry
.replace(/\r\n/g, "\n") // 标准化换行符
.replace(/\n{1,}/g, "\n") // 连续换行 → 仅保留1个
)
.filter(
(entry) =>
entry.trim() !== "" && !entry.trim().startsWith("heartbeat")
);

return cleaned;
};

// 重连逻辑
const attemptReconnect = () => {
if (retryCountRef.current >= maxRetries) {
console.warn("最大重连次数已达,停止重连");
setIsConnect(false);
return;
}

if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}

reconnectTimeoutRef.current = setTimeout(() => {
if (!abortControllerRef.current?.signal.aborted) {
retryCountRef.current += 1;
console.log(`重连尝试第 ${retryCountRef.current} 次`);
connectToSSE();
}
}, 2000);
};

// 断开连接
const reset = () => {
setBtnLoading(false);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsConnect(false);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};

// 输入数字变化处理
const onChangeInitialLines = (value: number | null) => {
if (value !== null) setInitialLines(value);
};

// 全屏切换
const toggleFullscreen = () => {
if (!screenfull.isEnabled || !divRef.current) return;

if (screenfull.isFullscreen) {
screenfull.exit();
} else {
screenfull.request(divRef.current);
}
};

return (
<div>
<div style={{ marginBottom: 12 }}>
<span style={{ marginRight: 10 }}>
<span>{t("first_latest")}:</span>
<InputNumber
style={{ width: 200, marginRight: 8 }}
placeholder={t("initial_content")}
min={0}
value={initialLines}
parser={(value: any) =>
value ? value.replace(/\D/g, "") : ""
}
formatter={(value: any) =>
value
? value.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
: ""
}
onChange={onChangeInitialLines}
/>
</span>

<Input
placeholder={t("log_file")}
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
style={{ width: 300, marginRight: 8 }}
disabled={isConnect}
/>

<Select
showSearch
allowClear
style={{ width: 300, marginRight: 8 }}
placeholder={t("select_log_file")}
value={filePath || undefined}
onChange={(value) => setFilePath(value)}
onSearch={(value) => setFilePath(value)}
options={predefinedPaths.map((path) => ({
label: path.name,
value: path.value,
}))}
filterOption={false}
disabled={isConnect}
/>

<Button
disabled={isConnect}
loading={btnLoading}
type="primary"
onClick={() => {
setBtnLoading(true);
setLogs([]);
connectToSSE();
}}
style={{ marginLeft: 8 }}
>
{t("connect")}
</Button>

<Button
disabled={!isConnect}
type="primary"
onClick={reset}
style={{ marginLeft: 8 }}
>
{t("disconnect")}
</Button>

<Button
type="primary"
onClick={toggleFullscreen}
style={{ marginLeft: 8 }}
>
{isFullscreen ? t("exit_fullscreen") : t("fullscreen")}
</Button>
</div>

<ul
className={`msgBox ${isFullscreen ? "fullscreen" : ""}`}
ref={divRef}
>
{logs.map((msg, index) => {
if (!Array.isArray(msg)) return null;
return (
<li key={index}>
<pre className="log-pre">{msg.join("\n")}</pre>
</li>
);
})}
</ul>
</div>
);
};

export default ServiceTable;