Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
bbb23ba
Fixes #4342
vetri15 May 2, 2026
e3f7040
Merge remote-tracking branch 'origin/master'
vetri15 May 2, 2026
5c12767
made skipped bytes text to show at all times including 0 bytes
vetri15 May 3, 2026
794c137
Merge branch 'codecentric:master' into master
vetri15 May 6, 2026
2358e7f
Implemented logfile chunk navigation and recovery improvements.
vetri15 May 24, 2026
01fed9b
it now renders empty lines using br instead of pre tag
vetri15 May 24, 2026
eb21719
carried over css styles of the table , td , tr , br from PR(#5388)
vetri15 May 24, 2026
a4fefba
viewing first line of logfile now possible
vetri15 May 25, 2026
4c82b8f
manual mode now simply navigates chunks , previously it will scroll d…
vetri15 May 25, 2026
3677742
added a minimal guard , so the scroll to bottom won't spill over from…
vetri15 May 25, 2026
26c0248
Previously, while suppressing 416 errors, other errors were also supp…
vetri15 May 25, 2026
c3ee7a5
corrected tool tip to show proper text
vetri15 May 25, 2026
7d88aef
handled logfile compression in manualmetadatapolling
vetri15 May 25, 2026
fcbfdd2
manual navigation renders with broken style , corrected by forcing ta…
vetri15 May 26, 2026
d4b05e6
retry logic added to auto recover
vetri15 May 26, 2026
d668728
416 error console spam when requesting near the end of file rectified
vetri15 May 27, 2026
18f5ccb
added auto recovery on manual mode too
vetri15 May 27, 2026
8938ab8
Updated manual chunk navigation to fill the full chunk window near fi…
vetri15 May 28, 2026
3f4e6fe
buttons are now disabled when retrying
vetri15 May 29, 2026
e6e07ab
now manual navigation scrolls to bottom instead of top
vetri15 May 29, 2026
ae0684e
Merge branch 'master' into issue-4342-logile-view
vetri15 May 29, 2026
6335ac2
reverted back to page up scrolling in the direction on first click an…
vetri15 May 30, 2026
bf28433
Merge remote-tracking branch 'origin/issue-4342-logile-view' into iss…
vetri15 May 30, 2026
c92696d
added dynamic tracking of the scrolled to line , so it is carried for…
vetri15 Jun 3, 2026
06a2f63
added highlight to the line when loading partial page
vetri15 Jun 8, 2026
6a4bfff
Merge branch 'revision-issue-4342-logile-view' into issue-4342-logile…
vetri15 Jun 8, 2026
13f06d1
broken line fix , added line key for lines.
vetri15 Jun 20, 2026
595f606
(Removed comments and unnecessary variables )broken line fix , added …
vetri15 Jun 20, 2026
b36b075
Merge branch 'revision-issue-4342-logile-view' into issue-4342-logile…
vetri15 Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import axios, {
registerErrorToastInterceptor,
} from '../utils/axios';
import waitForPolyfill from '../utils/eventsource-polyfill';
import logtail from '../utils/logtail';
import logtail, { getLogfileWindowMetadata } from '../utils/logtail';
import uri from '../utils/uri';

import { useSbaConfig } from '@/sba-config';
Expand Down Expand Up @@ -412,11 +412,35 @@ class Instance {

streamLogfile(interval: number) {
return logtail(
(opt) => this.axios.get(uri`actuator/logfile`, opt),
(opt) =>
this.axios.get(uri`actuator/logfile`, {
...opt,
suppressToast: (error: AxiosError) => error.response?.status === 416,
}),
interval,
);
}

async fetchLogfileRange(start: number, end: number): Promise<LogfileRange> {
const response = await this.axios.get(uri`actuator/logfile`, {
responseType: 'text',
headers: {
Accept: 'text/plain',
Range: `bytes=${start}-${end}`,
},
suppressToast: (error: AxiosError) => error.response?.status === 416,
});
const metadata = getLogfileWindowMetadata(response);

return {
data: response.data,
totalBytes: metadata.totalBytes,
windowStart: metadata.windowStart,
windowEnd: metadata.windowEnd,
status: response.status,
};
}

async listMBeans() {
return this.axios.get(uri`actuator/jolokia/list`, {
headers: { Accept: 'application/json' },
Expand Down Expand Up @@ -544,6 +568,14 @@ type Endpoint = {
url: string;
};

export type LogfileRange = {
data: string;
totalBytes: number;
windowStart: number;
windowEnd: number;
status: number;
};

export const DOWN_STATES = ['OUT_OF_SERVICE', 'DOWN', 'OFFLINE', 'RESTRICTED'];
export const UP_STATES = ['UP'];
export const UNKNOWN_STATES = ['UNKNOWN'];
288 changes: 241 additions & 47 deletions spring-boot-admin-server-ui/src/main/frontend/utils/logtail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,132 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EMPTY, Observable, catchError, concatMap, of, timer } from './rxjs';
import {
EMPTY,
Observable,
catchError,
concatMap,
of,
throwError,
timer,
} from './rxjs';

export default (getFn, interval, initialSize = 300 * 1024) => {
export const DEFAULT_LOGFILE_CHUNK_SIZE = 300 * 1024;

export enum StreamType {
Data = 'data',
Reset = 'reset',
Empty = 'empty',
}

export const ChunkDirection = Object.freeze({
PREVIOUS: 'previous',
NEXT: 'next',
REPLACE: 'replace',
});

export enum ContentType {
NormalContent = 'normalContent',
ShortContent = 'shortContent',
}

const parseInteger = (value, fallback) => {
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? fallback : parsed;
};

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

const byteLength = (content) => textEncoder.encode(content).length;
const substringFromByteOffset = (content, offset) =>
textDecoder.decode(textEncoder.encode(content).slice(offset));

export const getTotalBytesFrom416 = (response) => {
const contentRange = response.headers?.get?.('content-range');
const match = contentRange?.match(/^bytes\s+\*\/(\d+)$/i);

return match ? parseInteger(match[1], undefined) : undefined;
};

export const getLogfileWindowMetadata = (response) => {
const contentLength = byteLength(response.data);
const contentRange = response.headers.get('content-range');
const rangeMatch = contentRange?.match(/^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i);

if (rangeMatch) {
return {
windowStart: parseInteger(rangeMatch[1], 0),
windowEnd: parseInteger(rangeMatch[2], Math.max(contentLength - 1, 0)),
totalBytes: parseInteger(rangeMatch[3], contentLength),
};
}

const totalBytes = parseInteger(
response.headers.get('content-length'),
contentLength,
);

return {
windowStart: 0,
windowEnd: Math.max(contentLength - 1, 0),
totalBytes,
};
};

export const TrimtoCompleteLines = (
content: string,
windowStart: number,
windowEnd: number,
firstChunkSet: boolean,
) => {
const completeLineStart = content.indexOf('\n');
const completeLineEnd = content.lastIndexOf('\n');

if (completeLineEnd === -1) {
return {
trimmedCompleteLines: firstChunkSet ? content : undefined,
windowStart,
windowEnd,
contentType: ContentType.ShortContent,
};
}

let trimmedStart = 0;
if (!firstChunkSet && windowStart > 0) {
if (completeLineStart === completeLineEnd) {
return {
trimmedCompleteLines: undefined,
windowStart,
windowEnd,
contentType: ContentType.ShortContent,
};
}
trimmedStart = completeLineStart + 1;
}

const trimmedCompleteLines = content.substring(
trimmedStart,
completeLineEnd + 1,
);
const newWindowEnd =
windowEnd - byteLength(content.substring(completeLineEnd + 1));
const newWindowStart =
newWindowEnd - byteLength(trimmedCompleteLines) + 1;

return {
trimmedCompleteLines,
windowStart: newWindowStart,
windowEnd: newWindowEnd,
contentType: ContentType.NormalContent,
};
};

export default (getFn, interval, initialSize = DEFAULT_LOGFILE_CHUNK_SIZE) => {
let range = `bytes=-${initialSize}`;
let size = 0;
let atTheEnd = false;
let lastCompleteByte = -1;
let firstChunkSet = false;

return timer(0, interval).pipe(
concatMap(() => {
Expand All @@ -33,58 +153,132 @@ export default (getFn, interval, initialSize = 300 * 1024) => {
})
.catch((error) => observer.error(error));
}).pipe(
catchError((error) => of({ data: '', status: error.response.status })),
catchError((error) => {
if (error.response?.status !== 416) {
return throwError(() => error);
}
return of({
data: '',
status: error.response?.status,
headers: error.response?.headers,
});
}),
);
}),
concatMap((response) => {
let initial = size === 0;
const contentLength = response.data.length;

if (response.status === 200) {
if (!initial) {
throw 'Expected 206 - Partial Content on subsequent requests.';
//resetting when log file is compressed
if (response.status === 416) {
const currentSize = getTotalBytesFrom416(response);
if (currentSize === size) {
return of({ type: StreamType.Empty });
}
size = contentLength;
range = `bytes=${size - 1}-`;
} else if (response.status === 206) {
const contentRangeParts = response.headers['content-range'].split('/');
size = parseInt(contentRangeParts[1]);
// The end value of the range is always one byte less than the size when at the end
atTheEnd = parseInt(contentRangeParts[0].split('-')[1]) == size - 1;
range = `bytes=${size - 1}-`;
} else if (response.status === 416) {
size = 0;
range = `bytes=-${initialSize}`;
initial = true;
} else {
throw 'Unexpected response status: ' + response.status;
size = 0;
lastCompleteByte = -1;
firstChunkSet = false;
return of({ type: StreamType.Reset });
}

let addendum = null;
let skipped = 0;

if (initial) {
if (contentLength >= size) {
addendum = response.data;
} else {
// In case of a partial response find the first line break.
addendum = response.data.substring(response.data.indexOf('\n') + 1);
skipped = size - addendum.length;
const { windowStart, windowEnd, totalBytes } =
getLogfileWindowMetadata(response);
const overlap = firstChunkSet
? Math.max(lastCompleteByte - windowStart + 1, 0)
: 0;
let addendum = substringFromByteOffset(response.data, overlap);
let addendumWindowStart = windowStart + overlap;
let addendumWindowEnd = windowEnd;
if (response.status === 206 || response.status === 200) {
if (totalBytes > size) {
const trimmed = TrimtoCompleteLines(
addendum,
addendumWindowStart,
windowEnd,
firstChunkSet,
);
if(trimmed.contentType === ContentType.ShortContent){
return EMPTY;
}
if(!firstChunkSet){
firstChunkSet = true;
}
addendum = trimmed.trimmedCompleteLines;
addendumWindowStart = trimmed.windowStart;
addendumWindowEnd = trimmed.windowEnd;
size = totalBytes;
lastCompleteByte = trimmed.windowEnd
range = `bytes=${lastCompleteByte}-`;
return of(
{
type: StreamType.Data,
totalBytes: size,
addendum,
windowStart: addendumWindowStart,
windowEnd: addendumWindowEnd,
}
)
}else{
return EMPTY;
}
} else if (response.data.length > 1) {
// Remove the first byte which has been part of the previous response.
addendum = response.data.substring(1);
} else {
throw 'Unexpected response status: ' + response.status;
}

return addendum
? of({
totalBytes: size,
skipped,
// The log file always temporarily ends with a new line until the next one is written.
// Therefore, if we're at the end of it, we drop such a new line.
addendum: atTheEnd ? addendum.trimEnd() : addendum,
})
: EMPTY;
}),
);
};

export const fetchLogfileRange = async (instance, start, end, direction) => {
let { data, totalBytes, windowStart, windowEnd, status } = await instance.fetchLogfileRange(start, end);
//manual polling return type
if(start == 0 && end == 0){
return {
data,
totalBytes,
windowStart,
windowEnd,
status
}
}
let completeLineStart = data.indexOf('\n');
let completeLineEnd = data.lastIndexOf('\n');
const hasAtLeastTwoNewLines =
completeLineStart !== -1 && (completeLineStart !== completeLineEnd);
if(!hasAtLeastTwoNewLines){
throw new Error('Too few lines: need at least two lines to display properly');
}
if(ChunkDirection.NEXT === direction){
const trimmedCompleteLines = data.substring(0, completeLineEnd + 1);
const newWindowEnd =
windowEnd - byteLength(data.substring(completeLineEnd + 1));
return {
data: trimmedCompleteLines,
totalBytes,
windowStart,
windowEnd: newWindowEnd,
status
}
}
if (windowStart === 0) {
const trimmedCompleteLines = data.substring(0, completeLineEnd + 1);
const newWindowEnd =
windowEnd - byteLength(data.substring(completeLineEnd + 1));
return {
data: trimmedCompleteLines,
totalBytes,
windowStart,
windowEnd: newWindowEnd,
status
}
} else {
const trimmedCompleteLines = data.substring(completeLineStart + 1, completeLineEnd + 1);
const newWindowEnd =
windowEnd - byteLength(data.substring(completeLineEnd + 1));
const newWindowStart =
newWindowEnd - byteLength(trimmedCompleteLines) + 1;
return {
data: trimmedCompleteLines,
totalBytes,
windowStart: newWindowStart,
windowEnd: newWindowEnd,
status
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
debounceTime,
mergeWith,
map,
retry,
retryWhen,
tap,
filter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
"logfile": {
"label": "Log",
"download": "Herunterladen",
"wrap_lines": "Zeilen umbrechen"
"wrap_lines": "Zeilen umbrechen",
"page_up": "Seite nach oben",
"page_down": "Seite nach unten",
"previous_chunk": "Vorheriger Abschnitt",
"next_chunk": "Nächster Abschnitt",
"resume_follow": "Live-Ansicht fortsetzen",
"stop_follow": "Live-Ansicht anhalten",
"reconnecting": "Verbindung wird wiederhergestellt...",
"compressed_reset": "Logdatei komprimiert, Seite wird zurückgesetzt."
}
}
}
Loading