Skip to content

Commit ab9bf4d

Browse files
Zakaria-Kofirozkofiro
andauthored
Improve Admin Log Viewer with reliable streaming and investigation workspace (#498)
* Improve admin log viewer with reliable tank.log streaming and investigation UI. * Add hover tooltips explaining log viewer controls. * Remove Cursor rules files from the repository. * Remove CLAUDE.md from the repository. * Ignore local Cursor and Claude agent config files. * Add compact timeline clocks, gap deltas, and volume axis labels. --------- Co-authored-by: zkofiro <zakaria_kofiro@intuit.com>
1 parent 625c3bb commit ab9bf4d

21 files changed

Lines changed: 6764 additions & 338 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,9 @@ dmypy.json
179179

180180
# Pyre type checker
181181
.pyre/
182+
183+
# Local agent / IDE tooling (never commit)
184+
.cursor/
185+
CLAUDE.md
186+
AGENTS.md
187+
.codegraph/

rest-mvc/impl/src/main/java/com/intuit/tank/rest/mvc/rest/controllers/LogController.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,15 @@
77
*/
88
package com.intuit.tank.rest.mvc.rest.controllers;
99

10+
import com.intuit.tank.rest.mvc.rest.services.logs.LogFileResponse;
1011
import com.intuit.tank.rest.mvc.rest.services.logs.LogServiceV2;
1112

1213
import io.swagger.v3.oas.annotations.Operation;
13-
import io.swagger.v3.oas.annotations.Parameter;
1414
import io.swagger.v3.oas.annotations.tags.Tag;
15-
import org.springframework.http.HttpStatus;
1615
import org.springframework.http.MediaType;
1716
import org.springframework.http.ResponseEntity;
1817
import org.springframework.web.bind.annotation.*;
1918
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
20-
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
2119

2220
import jakarta.annotation.Resource;
2321
import java.io.IOException;
@@ -26,16 +24,21 @@
2624
@RequestMapping(value = "/v2/logs")
2725
@Tag(name = "Logs")
2826
public class LogController {
27+
static final String TOTAL_LENGTH_HEADER = "X-Total-Content-Length";
28+
static final String CONTENT_START_HEADER = "X-Content-Start";
29+
2930
@Resource
3031
private LogServiceV2 logServiceV2;
3132

3233
@RequestMapping(value = "/{filename}", method = RequestMethod.GET, produces = { MediaType.APPLICATION_OCTET_STREAM_VALUE })
3334
@Operation(description = "Retrieves a specific log file from logs", summary = "Get streaming log file output", hidden = true)
3435
public ResponseEntity<StreamingResponseBody> getFile(@PathVariable String filename, @RequestParam(required = false) String from) throws IOException {
35-
StreamingResponseBody response = logServiceV2.getFile(filename, from);
36+
LogFileResponse response = logServiceV2.getFile(filename, from);
3637

3738
return ResponseEntity.ok()
3839
.contentType(MediaType.APPLICATION_OCTET_STREAM)
39-
.body(response);
40+
.header(TOTAL_LENGTH_HEADER, Long.toString(response.totalLength()))
41+
.header(CONTENT_START_HEADER, Long.toString(response.startOffset()))
42+
.body(response.body());
4043
}
4144
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Copyright 2015-2026 Intuit Inc.
3+
* All rights reserved. This program and the accompanying materials
4+
* are made available under the terms of the Eclipse Public License v1.0
5+
* which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v10.html
7+
*/
8+
package com.intuit.tank.rest.mvc.rest.services.logs;
9+
10+
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
11+
12+
/**
13+
* Streaming log response and the byte range represented by its body.
14+
*/
15+
public record LogFileResponse(
16+
StreamingResponseBody body,
17+
long totalLength,
18+
long startOffset) {
19+
}

rest-mvc/impl/src/main/java/com/intuit/tank/rest/mvc/rest/services/logs/LogServiceV2.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
import com.intuit.tank.rest.mvc.rest.controllers.errors.GenericServiceResourceNotFoundException;
1111
import com.intuit.tank.rest.mvc.rest.controllers.errors.GenericServiceBadRequestException;
1212
import com.intuit.tank.rest.mvc.rest.controllers.errors.GenericServiceForbiddenAccessException;
13-
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
14-
1513
public interface LogServiceV2 {
1614

1715
/**
@@ -32,8 +30,8 @@ public interface LogServiceV2 {
3230
* @throws GenericServiceForbiddenAccessException
3331
* if user not authorized to access file
3432
*
35-
* @return streaming output of log file
33+
* @return streaming output and byte-range metadata for the log file
3634
*/
37-
public StreamingResponseBody getFile(String filePath, String start);
35+
LogFileResponse getFile(String filePath, String start);
3836

3937
}
Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright 2015-2023 Intuit Inc.
2+
* Copyright 2015-2026 Intuit Inc.
33
* All rights reserved. This program and the accompanying materials
44
* are made available under the terms of the Eclipse Public License v1.0
55
* which accompanies this distribution, and is available at
@@ -11,53 +11,51 @@
1111
import com.intuit.tank.rest.mvc.rest.controllers.errors.GenericServiceForbiddenAccessException;
1212
import com.intuit.tank.rest.mvc.rest.controllers.errors.GenericServiceResourceNotFoundException;
1313
import com.intuit.tank.rest.mvc.rest.util.FileReader;
14+
import com.intuit.tank.rest.mvc.rest.util.LogDirectory;
1415

1516
import org.apache.logging.log4j.LogManager;
1617
import org.apache.logging.log4j.Logger;
17-
import org.springframework.beans.factory.annotation.Autowired;
1818
import org.springframework.stereotype.Service;
19-
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
2019

21-
import jakarta.servlet.ServletContext;
2220
import java.io.File;
2321

2422
@Service
2523
public class LogServiceV2Impl implements LogServiceV2 {
2624

27-
@Autowired
28-
private ServletContext servletContext;
29-
3025
private static final Logger LOGGER = LogManager.getLogger(LogServiceV2Impl.class);
3126

3227
@Override
33-
public StreamingResponseBody getFile(String filePath, String start) {
34-
StreamingResponseBody streamingResponse;
28+
public LogFileResponse getFile(String filePath, String start) {
3529
start = start == null ? "0" : start;
3630
try {
37-
if (filePath.contains("..") || filePath.startsWith("/")) {
31+
if (filePath == null || filePath.contains("..") || filePath.startsWith("/") || filePath.contains("\\")) {
3832
LOGGER.error("Error returning file: incorrect file path");
3933
throw new GenericServiceBadRequestException("logs", "file path", "incorrect file path");
40-
} else {
41-
String rootDir = "logs";
42-
final File f = new File(rootDir, filePath);
43-
if (!f.exists()) {
44-
LOGGER.error("Error returning file: file does not exist");
45-
throw new GenericServiceResourceNotFoundException("logs", "file", null);
46-
} else if (!f.isFile()) {
47-
LOGGER.error("Error returning file: not a file");
48-
throw new GenericServiceBadRequestException("logs", "file path", "not a file");
49-
} else if (!f.canRead()) {
50-
LOGGER.error("Error returning file: user not authorized to access file");
51-
throw new GenericServiceForbiddenAccessException("logs", "file");
52-
} else {
53-
long total = f.length();
54-
streamingResponse = FileReader.getFileStreamingResponseBody(f, total, start);
55-
}
5634
}
35+
36+
final File f = LogDirectory.findFile(filePath);
37+
if (f == null || !f.exists()) {
38+
LOGGER.error("Error returning file: file does not exist in {}", LogDirectory.candidateRoots());
39+
throw new GenericServiceResourceNotFoundException("logs", "file", null);
40+
}
41+
if (!f.isFile()) {
42+
LOGGER.error("Error returning file: not a file");
43+
throw new GenericServiceBadRequestException("logs", "file path", "not a file");
44+
}
45+
if (!f.canRead()) {
46+
LOGGER.error("Error returning file: user not authorized to access file");
47+
throw new GenericServiceForbiddenAccessException("logs", "file");
48+
}
49+
50+
long total = f.length();
51+
FileReader.FileStream stream = FileReader.getFileStream(f, total, start);
52+
return new LogFileResponse(stream.body(), stream.totalLength(), stream.startOffset());
53+
} catch (GenericServiceBadRequestException | GenericServiceResourceNotFoundException
54+
| GenericServiceForbiddenAccessException e) {
55+
throw e;
5756
} catch (Exception e) {
58-
LOGGER.error("Error returning file: file could not be found");
57+
LOGGER.error("Error returning file: file could not be found", e);
5958
throw new GenericServiceResourceNotFoundException("logs", "file", null);
6059
}
61-
return streamingResponse;
6260
}
6361
}

rest-mvc/impl/src/main/java/com/intuit/tank/rest/mvc/rest/util/FileReader.java

Lines changed: 71 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,28 @@
88
package com.intuit.tank.rest.mvc.rest.util;
99

1010
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
11-
import org.apache.commons.io.FileUtils;
1211
import org.apache.logging.log4j.LogManager;
1312
import org.apache.logging.log4j.Logger;
1413

1514
import java.io.File;
1615
import java.io.FileInputStream;
1716
import java.io.IOException;
1817
import java.io.OutputStream;
18+
import java.io.RandomAccessFile;
1919
import java.nio.channels.Channels;
2020
import java.nio.channels.FileChannel;
2121
import java.nio.channels.WritableByteChannel;
22-
import java.nio.charset.StandardCharsets;
23-
import java.util.Collections;
24-
import java.util.List;
2522

2623
public final class FileReader {
2724

25+
private static final int TAIL_BUFFER_SIZE = 8192;
26+
27+
public record FileStream(
28+
StreamingResponseBody body,
29+
long totalLength,
30+
long startOffset) {
31+
}
32+
2833
private FileReader() {
2934
// empty private constructor
3035
}
@@ -40,39 +45,49 @@ private FileReader() {
4045
* file
4146
*
4247
* @param total
43-
* total number of lines
48+
* total number of bytes
4449
*
4550
* @param start
46-
* starting line number
51+
* starting byte offset, or a negative number of lines from the end
4752
*
4853
* @return a StreamingResponseBody
4954
*/
50-
public static StreamingResponseBody getFileStreamingResponseBody(final File f, long total, String start) {
55+
public static FileStream getFileStream(final File f, long total, String start) {
56+
final long from = resolveStartOffset(f, total, start);
57+
final long count = total - from;
5158

52-
long l = 0;
53-
if (start != null) {
54-
try {
55-
l = Long.parseLong(start);
56-
// num lines to get from end
57-
if (l < 0) {
58-
l = getStartChar(f, Math.abs(l), total);
59-
}
60-
} catch (Exception e) {
61-
LOG.error("Error parsing start " + start + ": " + e);
59+
StreamingResponseBody body = (final OutputStream output) -> {
60+
try (FileInputStream inputStream = new FileInputStream(f);
61+
FileChannel inputChannel = inputStream.getChannel();
62+
WritableByteChannel outputChannel = Channels.newChannel(output)) {
63+
inputChannel.transferTo(from, count, outputChannel);
6264
}
65+
};
66+
LOG.debug("returning data from " + from + " - " + total + " of total " + total);
67+
return new FileStream(body, total, from);
68+
}
69+
70+
public static StreamingResponseBody getFileStreamingResponseBody(final File f, long total, String start) {
71+
return getFileStream(f, total, start).body();
72+
}
73+
74+
private static long resolveStartOffset(File f, long total, String start) {
75+
if (start == null) {
76+
return 0;
6377
}
64-
final long to = l > total ? 0 : total;
65-
final long from = l;
6678

67-
StreamingResponseBody streamer = (final OutputStream output) -> {
68-
try (FileChannel inputChannel = new FileInputStream(f).getChannel();
69-
WritableByteChannel outputChannel = Channels.newChannel(output)) {
70-
inputChannel.transferTo(from, to, outputChannel);
79+
try {
80+
long requestedOffset = Long.parseLong(start);
81+
if (requestedOffset < 0) {
82+
return getStartByte(f, Math.abs(requestedOffset), total);
7183
}
72-
// closing the channels
73-
};
74-
LOG.debug("returning data from " + from + " - " + to + " of total " + total);
75-
return streamer;
84+
// Offsets past EOF yield an empty slice at EOF so clients can detect
85+
// truncation via X-Content-Start without replaying the whole file.
86+
return Math.min(Math.max(0, requestedOffset), total);
87+
} catch (Exception e) {
88+
LOG.error("Error parsing start " + start + ": " + e);
89+
return 0;
90+
}
7691
}
7792

7893
/**
@@ -82,18 +97,37 @@ public static StreamingResponseBody getFileStreamingResponseBody(final File f, l
8297
* @return
8398
* @throws IOException
8499
*/
85-
@SuppressWarnings({ "unchecked" })
86-
private static long getStartChar(File f, long numLines, long total) throws IOException {
87-
List<String> lines = FileUtils.readLines(f, StandardCharsets.UTF_8);
88-
long count = 0;
89-
if (lines.size() > numLines) {
90-
Collections.reverse(lines);
91-
for (int i = 0; i < numLines; i++) {
92-
count += lines.get(i).length() + 1;
100+
private static long getStartByte(File f, long numLines, long total) throws IOException {
101+
if (numLines == 0 || total == 0) {
102+
return 0;
103+
}
104+
105+
long newlines = 0;
106+
try (RandomAccessFile file = new RandomAccessFile(f, "r")) {
107+
long blockEnd = total;
108+
byte[] buffer = new byte[TAIL_BUFFER_SIZE];
109+
while (blockEnd > 0) {
110+
int blockLength = (int) Math.min(TAIL_BUFFER_SIZE, blockEnd);
111+
long blockStart = blockEnd - blockLength;
112+
file.seek(blockStart);
113+
file.readFully(buffer, 0, blockLength);
114+
115+
for (int i = blockLength - 1; i >= 0; i--) {
116+
long position = blockStart + i;
117+
if (buffer[i] == '\n') {
118+
if (position == total - 1) {
119+
continue;
120+
}
121+
newlines++;
122+
if (newlines == numLines) {
123+
return position + 1;
124+
}
125+
}
126+
}
127+
blockEnd = blockStart;
93128
}
94-
count = total - (count + 1);
95129
}
96-
return count;
130+
return 0;
97131
}
98132

99133
}

0 commit comments

Comments
 (0)