diff --git a/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts b/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts index edb7159475b..00cf27653ff 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/services/instance.ts @@ -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'; @@ -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 { + 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' }, @@ -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']; diff --git a/spring-boot-admin-server-ui/src/main/frontend/utils/logtail.ts b/spring-boot-admin-server-ui/src/main/frontend/utils/logtail.ts index 5e5d0c6c076..65581c61b51 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/utils/logtail.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/utils/logtail.ts @@ -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(() => { @@ -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 + } + } +}; diff --git a/spring-boot-admin-server-ui/src/main/frontend/utils/rxjs.ts b/spring-boot-admin-server-ui/src/main/frontend/utils/rxjs.ts index a3b5ad9c564..3f47975c1f1 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/utils/rxjs.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/utils/rxjs.ts @@ -32,6 +32,7 @@ export { debounceTime, mergeWith, map, + retry, retryWhen, tap, filter, diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.de.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.de.json index 1666c0e9df8..866f21d6f34 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.de.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.de.json @@ -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." } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.en.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.en.json index 8e0e2b909f9..df189814d9d 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.en.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.en.json @@ -3,7 +3,15 @@ "logfile": { "label": "Logfile", "download": "Download", - "wrap_lines": "Wrap lines" + "wrap_lines": "Wrap lines", + "page_up": "Page up", + "page_down": "Page down", + "previous_chunk": "Previous chunk", + "next_chunk": "Next chunk", + "resume_follow": "Resume follow", + "stop_follow": "Stop following", + "reconnecting": "Reconnecting...", + "compressed_reset": "Logfile compressed, resetting page." } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.es.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.es.json index 724c7d54e6b..d786c9d2bcd 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.es.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.es.json @@ -3,8 +3,15 @@ "logfile": { "label": "Archivo de log", "download": "Descargar", - - "wrap_lines": "Ajustar líneas" + "wrap_lines": "Ajustar líneas", + "page_up": "Subir página", + "page_down": "Bajar página", + "previous_chunk": "Fragmento anterior", + "next_chunk": "Fragmento siguiente", + "resume_follow": "Reanudar seguimiento", + "stop_follow": "Detener seguimiento", + "reconnecting": "Reconectando...", + "compressed_reset": "Archivo de registro comprimido, restableciendo la página" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.fr.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.fr.json index 8a5c72e802e..80dd7ddc10c 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.fr.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.fr.json @@ -2,7 +2,16 @@ "instances": { "logfile": { "label": "Fichier de log", - "download": "Téléchargement" + "download": "Téléchargement", + "wrap_lines": "Retour à la ligne", + "page_up": "Page vers le haut", + "page_down": "Page vers le bas", + "previous_chunk": "Segment précédent", + "next_chunk": "Segment suivant", + "resume_follow": "Reprendre le suivi", + "stop_follow": "Arrêter le suivi", + "reconnecting": "Reconnexion...", + "compressed_reset": "Fichier journal compressé, réinitialisation de la page" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.is.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.is.json index 0c0dfcbbee7..0fc7d34216d 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.is.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.is.json @@ -2,7 +2,16 @@ "instances": { "logfile": { "label": "Annálaskrá", - "download": "Niðurhal" + "download": "Niðurhal", + "wrap_lines": "Línuskipting", + "page_up": "Síða upp", + "page_down": "Síða niður", + "previous_chunk": "Fyrri hluti", + "next_chunk": "Næsti hluti", + "resume_follow": "Fylgja aftur", + "stop_follow": "Hætta að fylgja", + "reconnecting": "Endurtengist...", + "compressed_reset": "Atvikaskrá þjöppuð, síðan er endurstillt" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.ko.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.ko.json index cd8994f94cc..45b4ca84490 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.ko.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.ko.json @@ -2,7 +2,16 @@ "instances": { "logfile": { "label": "로그파일", - "download": "다운로드" + "download": "다운로드", + "wrap_lines": "줄 바꿈", + "page_up": "페이지 위로", + "page_down": "페이지 아래로", + "previous_chunk": "이전 구간", + "next_chunk": "다음 구간", + "resume_follow": "실시간 추적 재개", + "stop_follow": "실시간 추적 중지", + "reconnecting": "다시 연결 중...", + "compressed_reset": "로그 파일이 압축되었습니다. 페이지를 재설정합니다" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.pt-BR.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.pt-BR.json index b11ff6016da..2fa1889364f 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.pt-BR.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.pt-BR.json @@ -2,7 +2,16 @@ "instances": { "logfile": { "label": "Arquivo de Log", - "download": "Download" + "download": "Download", + "wrap_lines": "Quebrar linhas", + "page_up": "Subir página", + "page_down": "Descer página", + "previous_chunk": "Trecho anterior", + "next_chunk": "Próximo trecho", + "resume_follow": "Retomar acompanhamento", + "stop_follow": "Parar acompanhamento", + "reconnecting": "Reconectando...", + "compressed_reset": "Arquivo de log compactado, redefinindo a página" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.ru.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.ru.json index 7cf7a720722..12ef10bdf61 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.ru.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.ru.json @@ -2,7 +2,16 @@ "instances": { "logfile": { "label": "Лог-файл", - "download": "Загрузить" + "download": "Загрузить", + "wrap_lines": "Перенос строк", + "page_up": "Страница вверх", + "page_down": "Страница вниз", + "previous_chunk": "Предыдущий фрагмент", + "next_chunk": "Следующий фрагмент", + "resume_follow": "Возобновить слежение", + "stop_follow": "Остановить слежение", + "reconnecting": "Повторное подключение...", + "compressed_reset": "Файл журнала сжат, страница сбрасывается" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.zh-CN.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.zh-CN.json index ecb7c1ee088..7dfd61ed92c 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.zh-CN.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.zh-CN.json @@ -2,7 +2,16 @@ "instances": { "logfile": { "label": "日志文件", - "download": "下载" + "download": "下载", + "wrap_lines": "自动换行", + "page_up": "向上翻页", + "page_down": "向下翻页", + "previous_chunk": "上一段", + "next_chunk": "下一段", + "resume_follow": "恢复跟踪", + "stop_follow": "停止跟踪", + "reconnecting": "正在重新连接...", + "compressed_reset": "日志文件已压缩,正在重置页面。" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.zh-TW.json b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.zh-TW.json index 3d2fa9a1d77..5d0603510e5 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.zh-TW.json +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/i18n.zh-TW.json @@ -3,7 +3,15 @@ "logfile": { "label": "日誌檔案", "download": "下載", - "wrap_lines": "自動換行" + "wrap_lines": "自動換行", + "page_up": "向上翻頁", + "page_down": "向下翻頁", + "previous_chunk": "上一段", + "next_chunk": "下一段", + "resume_follow": "恢復追蹤", + "stop_follow": "停止追蹤", + "reconnecting": "正在重新連線...", + "compressed_reset": "記錄檔已壓縮,正在重設頁面。" } } } diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/index.vue b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/index.vue index 0e906e31e4e..6874f0f5e1a 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/index.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/views/instances/logfile/index.vue @@ -26,7 +26,17 @@
  -