发布于 

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
src
-views
-serviceTable
-serviceTable.less
-serviceTable.tsx

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

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
.msgBox {
width: 100%;
height: calc(100vh - 150px);
flex-wrap: wrap;
overflow: auto;
background: black;
color: rgb(220, 220, 220);
padding: 20px;
list-style-type: none;
/* 去掉圆点 */
padding-left: 10px;

/* 去掉左边的缩进 */
li {
font-size: 18px;
line-height: 26px;
color: e7e7e7;

.li-item {
white-space: nowrap;
padding: 4px 0;
}
}
}

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
import { useEffect, useState, useRef } from "react";
import { Button, Input, InputNumber } from "antd";
import { useEventBus } from "@/useEventBus";
import "./serviceTable.less";
const baseURL = import.meta.env.VITE_API_URL;

function EmailTable() {
const divRef = useRef<any>(null);
const [filePath, setFilePath] = useState<string>("");
const [logs, setLogs] = useState<any[]>([]);
const token = localStorage.getItem("admin_token");
const [initialLines, setInitialLines] = useState<number>(100);
const [btnLoading, setBtnLoading] = useState<boolean>(false);
const [isConnect, setIsConnect] = useState<boolean>(false);

const abortControllerRef = useRef<AbortController | null>(null); // 用于存储 AbortController 实例
const reconnectTimeoutRef = useRef<any | null>(null); // 用于存储重连定时器
let retryCount = 0; // 定义重连计数器
const maxRetries = 3; // 最大重连次数
// 订阅事件
useEventBus("message", (data: any) => {
if (data && data !== "log/serviceTable") {
setLogs([]);
setInitialLines(100);
setFilePath("");
reset();
}
});

function attemptReconnect() {
if (retryCount >= maxRetries) {
// 如果重连次数已经超过最大值,则直接返回,不再尝试重连
console.log(
"Maximum retry attempts reached. No further reconnection will be attempted."
);
return;
}
reconnectTimeoutRef.current = setTimeout(() => {
if (!abortControllerRef.current?.signal.aborted) {
console.log(
`Attempting to reconnect... (Retry ${retryCount + 1})`
);
connectToSSE(); // 心跳重连

retryCount++; // 增加重连计数
console.log(retryCount, maxRetries);
if (retryCount < maxRetries) {
// 如果重连次数未达到最大值,则继续尝试重连
attemptReconnect();
} else {
// 达到最大重连次数后,清除计时器并停止重连
console.log(
"Maximum retry attempts reached. No further reconnection will be attempted."
);
clearTimeout(reconnectTimeoutRef.current);
}
} else {
// 如果连接已被中止,则清除计时器
clearTimeout(reconnectTimeoutRef.current);
}
}, 2000); // 5秒后重试
}

const connectToSSE = async () => {
// 如果已经存在 AbortController,先中止之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}

// 创建新的 AbortController
abortControllerRef.current = new AbortController();

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

try {
// 发起带有自定义 Headers 的 Fetch 请求
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: token || "",
"Content-Type": "application/json",
},
signal: abortControllerRef.current.signal, // 绑定 AbortSignal
});
console.log("start", new Date());
console.log("response", response);
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");
}
if (response.ok && response.body) {
setIsConnect(true); // 设置连接状态为 true
}

const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
const status = true;
while (status) {
const { done, value } = await reader.read();
if (done) {
console.log("SSE stream closed");
console.log(new Date());
// 心跳重连机制
attemptReconnect();
break;
}

const chunk = decoder.decode(value);
const messages = formatLog(chunk);
setLogs((prevData) => [...prevData, messages]);

setTimeout(() => {
if (divRef.current) {
divRef.current.scrollTop = divRef.current.scrollHeight;
}
}, 1000);
}
} catch (error: any) {
console.error("Error connecting to SSE:", error);
setBtnLoading(false);
if (error.name === "AbortError") {
console.log("SSE request was aborted");
} else {
console.error("SSE error:", error);
}
// 不设置 setIsConnect(false),等待重连
}
};

// 手动断开逻辑
const reset = () => {
setBtnLoading(false);
if (abortControllerRef.current) {
abortControllerRef.current.abort(); // 中止请求
abortControllerRef.current = null;
setIsConnect(false); // 手动断开时设置连接状态为 false
}
// 清理重连定时器
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};

// 组件卸载时清理资源
useEffect(() => {
return () => {
// 清理 AbortController
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}

// 清理重连定时器
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}

// 清理日志和状态
setLogs([]);
setFilePath("");
setInitialLines(100);
setIsConnect(false);

console.log("Component unmounted and resources cleaned up.");
};
}, []);

const handleSearch = () => {
setBtnLoading(true);
setLogs([]);
connectToSSE();
};

const onChange = (value: any) => {
console.log("changed", value);
if (value) {
setInitialLines(value);
}
};

const formatLog = (logString: string): string[] => {
return logString
.split("data:")
.filter(
(entry) =>
entry.trim() !== "" && !entry.trim().startsWith("heartbeat")
)
.map((entry) => entry.trim());
};

return (
<div>
<div>
<span style={{ marginRight: "10px" }}>
<span>首次加载最新行数:</span>
<InputNumber
style={{ width: 200, marginRight: 8 }}
placeholder="请输入初始内容长度,默认100"
min={0}
value={initialLines}
parser={(value: any) => value.replace(/\D/g, "")} // 只允许输入数字
formatter={(value) =>
value.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
} // 格式化输入为千分位
onChange={onChange}
/>
</span>

<Input
placeholder="请输入日志文件路径"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
style={{ width: 300, marginRight: 8 }}
/>
<Button
disabled={isConnect}
loading={btnLoading}
type="primary"
onClick={handleSearch}
style={{ marginLeft: 8 }}
>
连接
</Button>
<Button
disabled={!isConnect}
type="primary"
onClick={reset}
style={{ marginLeft: 8 }}
>
断开
</Button>
</div>
<ul className="msgBox" ref={divRef}>
{logs.map((msg, index) => {
if (!Array.isArray(msg)) {
console.warn(
`Invalid log entry at index ${index}:`,
msg
);
return null; // 跳过无效的日志项
}
return (
<li key={index}>
{msg
.join("*&*&*u34u394&") // 将数组转为字符串
.split("*&*&*u34u394&") // 拆分字符串
.map((part: any, i: number) => (
<div className="li-item" key={i}>
{part}
</div>
))}
</li>
);
})}
</ul>
</div>
);
}

export default EmailTable;