549 feature request add logging section (#550)

This commit is contained in:
Daniel Graf
2025-12-15 18:28:54 +01:00
committed by GitHub
parent 2a07f7d339
commit e022eebcb2
9 changed files with 702 additions and 3 deletions

View File

@@ -0,0 +1,28 @@
package com.dedicatedcode.reitti.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "reitti.logging")
public class LoggingProperties {
private int bufferSize = 1000;
private int maxBufferSize = 10000;
public int getBufferSize() {
return bufferSize;
}
public void setBufferSize(int bufferSize) {
this.bufferSize = bufferSize;
}
public int getMaxBufferSize() {
return maxBufferSize;
}
public void setMaxBufferSize(int maxBufferSize) {
this.maxBufferSize = maxBufferSize;
}
}

View File

@@ -43,6 +43,7 @@ public class SecurityConfig {
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/login", "/access", "/error").permitAll()
.requestMatchers("/settings/integrations/reitti.properties").hasAnyRole(Role.ADMIN.name(), Role.API_ACCESS.name(), Role.USER.name())
.requestMatchers("/settings/logging", "/settings/logging/**").hasRole(Role.ADMIN.name())
.requestMatchers("/settings/**").hasAnyRole(Role.ADMIN.name(), Role.USER.name())
.requestMatchers("/api/v1/photos/**").hasAnyRole(Role.ADMIN.name(),
Role.USER.name(),

View File

@@ -0,0 +1,98 @@
package com.dedicatedcode.reitti.controller.settings;
import com.dedicatedcode.reitti.model.Role;
import com.dedicatedcode.reitti.model.security.User;
import com.dedicatedcode.reitti.service.logging.LoggingService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@Controller
@RequestMapping("/settings/logging")
public class LoggingController {
private final LoggingService loggingService;
private final boolean dataManagementEnabled;
public LoggingController(LoggingService loggingService,
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
this.loggingService = loggingService;
this.dataManagementEnabled = dataManagementEnabled;
}
@GetMapping
public String loggingPage(@AuthenticationPrincipal User user, Model model) {
model.addAttribute("activeSection", "logging");
model.addAttribute("dataManagementEnabled", dataManagementEnabled);
model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
model.addAttribute("currentBufferSize", loggingService.getCurrentBufferSize());
model.addAttribute("maxBufferSize", loggingService.getMaxBufferSize());
model.addAttribute("currentLogLevel", loggingService.getCurrentLogLevel());
model.addAttribute("configuredLoggers", loggingService.getAllConfiguredLoggers());
return "settings/logging";
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamLogs() {
return loggingService.createLogStream();
}
@PostMapping("/update")
public String updateLoggingSettings(@RequestParam("logger") String logger,
@RequestParam("level") String level,
@RequestParam("size") int size,
@AuthenticationPrincipal User user,
Model model) {
try {
String loggerName = (logger == null || logger.trim().isEmpty()) ? "ROOT" : logger.trim();
loggingService.setLoggerLevel(loggerName, level);
loggingService.setBufferSize(size);
// Refresh model attributes for the fragment
model.addAttribute("currentBufferSize", loggingService.getCurrentBufferSize());
model.addAttribute("maxBufferSize", loggingService.getMaxBufferSize());
model.addAttribute("currentLogLevel", loggingService.getCurrentLogLevel());
model.addAttribute("configuredLoggers", loggingService.getAllConfiguredLoggers());
return "settings/logging :: logging-settings-card";
} catch (Exception e) {
// For errors, we could return an error fragment or handle differently
model.addAttribute("error", "Error updating logging settings: " + e.getMessage());
model.addAttribute("currentBufferSize", loggingService.getCurrentBufferSize());
model.addAttribute("maxBufferSize", loggingService.getMaxBufferSize());
model.addAttribute("currentLogLevel", loggingService.getCurrentLogLevel());
model.addAttribute("configuredLoggers", loggingService.getAllConfiguredLoggers());
return "settings/logging :: logging-settings-card";
}
}
@PostMapping("/remove")
public String removeLogger(@RequestParam("logger") String logger,
@AuthenticationPrincipal User user,
Model model) {
try {
loggingService.removeLogger(logger);
// Refresh model attributes for the fragment
model.addAttribute("currentBufferSize", loggingService.getCurrentBufferSize());
model.addAttribute("maxBufferSize", loggingService.getMaxBufferSize());
model.addAttribute("currentLogLevel", loggingService.getCurrentLogLevel());
model.addAttribute("configuredLoggers", loggingService.getAllConfiguredLoggers());
return "settings/logging :: logging-settings-card";
} catch (Exception e) {
model.addAttribute("error", "Error removing logger: " + e.getMessage());
model.addAttribute("currentBufferSize", loggingService.getCurrentBufferSize());
model.addAttribute("maxBufferSize", loggingService.getMaxBufferSize());
model.addAttribute("currentLogLevel", loggingService.getCurrentLogLevel());
model.addAttribute("configuredLoggers", loggingService.getAllConfiguredLoggers());
return "settings/logging :: logging-settings-card";
}
}
}

View File

@@ -0,0 +1,87 @@
package com.dedicatedcode.reitti.service.logging;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
@Component
public class InMemoryLogAppender extends AppenderBase<ILoggingEvent> {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private final List<String> buffer = new CopyOnWriteArrayList<>();
private final List<Consumer<String>> listeners = new CopyOnWriteArrayList<>();
private volatile int maxSize = 1000;
public void setBufferSize(int size) {
if (size <= 0) {
throw new IllegalArgumentException("Buffer size must be positive");
}
synchronized (this) {
this.maxSize = size;
// Trim buffer if it's now too large
while (buffer.size() > maxSize) {
buffer.remove(0);
}
}
}
public int getBufferSize() {
return maxSize;
}
public List<String> getSnapshot() {
return new ArrayList<>(buffer);
}
public void addListener(Consumer<String> listener) {
listeners.add(listener);
}
public void removeListener(Consumer<String> listener) {
listeners.remove(listener);
}
@Override
protected void append(ILoggingEvent event) {
String formattedMessage = formatLogEvent(event);
synchronized (this) {
buffer.add(formattedMessage);
// Remove oldest entries if buffer is full
while (buffer.size() > maxSize) {
buffer.remove(0);
}
}
// Notify all listeners
for (Consumer<String> listener : listeners) {
try {
listener.accept(formattedMessage);
} catch (Exception e) {
// Ignore listener errors to prevent logging loops
}
}
}
private String formatLogEvent(ILoggingEvent event) {
String timestamp = Instant.ofEpochMilli(event.getTimeStamp())
.atZone(ZoneId.systemDefault())
.format(FORMATTER);
return String.format("%s [%s] %s - %s%n",
timestamp,
event.getLevel(),
event.getLoggerName(),
event.getFormattedMessage());
}
}

View File

@@ -0,0 +1,190 @@
package com.dedicatedcode.reitti.service.logging;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import com.dedicatedcode.reitti.config.LoggingProperties;
import jakarta.annotation.PreDestroy;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@Service
public class LoggingService {
private static final org.slf4j.Logger log = LoggerFactory.getLogger(LoggingService.class);
private final LoggingProperties loggingProperties;
private final InMemoryLogAppender logAppender;
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
@Autowired
public LoggingService(LoggingProperties loggingProperties, InMemoryLogAppender logAppender) {
this.loggingProperties = loggingProperties;
this.logAppender = logAppender;
// Initialize the appender with the configured buffer size
logAppender.setBufferSize(loggingProperties.getBufferSize());
// Add the appender to the root logger
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
Logger rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME);
logAppender.setContext(context);
logAppender.start();
rootLogger.addAppender(logAppender);
}
public void setLoggerLevel(String loggerName, String level) {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
Logger logger = context.getLogger(loggerName);
Level logbackLevel = Level.valueOf(level.toUpperCase());
logger.setLevel(logbackLevel);
}
public void setBufferSize(int size) {
if (size > loggingProperties.getMaxBufferSize()) {
throw new IllegalArgumentException("Buffer size cannot exceed " + loggingProperties.getMaxBufferSize());
}
if (size <= 0) {
throw new IllegalArgumentException("Buffer size must be positive");
}
logAppender.setBufferSize(size);
}
public int getCurrentBufferSize() {
return logAppender.getBufferSize();
}
public int getMaxBufferSize() {
return loggingProperties.getMaxBufferSize();
}
public List<String> getLogSnapshot() {
return logAppender.getSnapshot();
}
public String getCurrentLogLevel() {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
Logger rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME);
Level level = rootLogger.getLevel();
return level != null ? level.toString() : "INFO";
}
public List<LoggerInfo> getAllConfiguredLoggers() {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
List<LoggerInfo> loggers = new ArrayList<>();
// Add root logger
Logger rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME);
Level rootLevel = rootLogger.getLevel();
if (rootLevel != null) {
loggers.add(new LoggerInfo("ROOT", rootLevel.toString()));
}
// Add all other configured loggers
for (Logger logger : context.getLoggerList()) {
if (logger.getLevel() != null && !Logger.ROOT_LOGGER_NAME.equals(logger.getName())) {
loggers.add(new LoggerInfo(logger.getName(), logger.getLevel().toString()));
}
}
return loggers.stream()
.sorted((a, b) -> {
// ROOT logger first, then alphabetically
if ("ROOT".equals(a.name())) return -1;
if ("ROOT".equals(b.name())) return 1;
return a.name().compareTo(b.name());
})
.collect(Collectors.toList());
}
public void removeLogger(String loggerName) {
if ("ROOT".equals(loggerName)) {
throw new IllegalArgumentException("Cannot remove ROOT logger");
}
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
Logger logger = context.getLogger(loggerName);
logger.setLevel(null); // Reset to inherit from parent
}
public SseEmitter createLogStream() {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
emitters.add(emitter);
try {
List<String> snapshot = getLogSnapshot();
for (String logLine : snapshot) {
emitter.send(SseEmitter.event()
.name("log")
.data(formatLogLineForHtml(logLine)));
}
} catch (IOException e) {
emitter.completeWithError(e);
return emitter;
}
Consumer<String> listener = logLine -> {
try {
emitter.send(SseEmitter.event()
.name("log")
.data(formatLogLineForHtml(logLine)));
} catch (IOException e) {
emitter.completeWithError(e);
}
};
logAppender.addListener(listener);
emitter.onCompletion(() -> {
emitters.remove(emitter);
logAppender.removeListener(listener);
});
emitter.onTimeout(() -> {
emitters.remove(emitter);
logAppender.removeListener(listener);
});
emitter.onError((ignored) -> {
emitters.remove(emitter);
logAppender.removeListener(listener);
});
return emitter;
}
@PreDestroy
public void cleanup() {
System.out.println("Closing SSE emitters");;
for (SseEmitter emitter : emitters) {
try {
emitter.complete();
} catch (Exception ignored) {}
}
emitters.clear();
}
private String formatLogLineForHtml(String logLine) {
String escaped = logLine
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
return "<div class=\"log-line\">" + escaped + "</div>";
}
public record LoggerInfo(String name, String level) {
}
}

View File

@@ -105,6 +105,10 @@ reitti.storage.cleanup.cron=0 0 4 * * *
# Location data density normalization
reitti.location.density.target-points-per-minute=2
# Logging configuration
reitti.logging.buffer-size=1000
reitti.logging.max-buffer-size=10000
# For OIDC security configuration, create a separate oidc.properties file instead of configuring OIDC settings directly in this file. See the oidc.properties.example for the needed properties.
spring.config.import=optional:oidc.properties

View File

@@ -100,6 +100,7 @@ settings.places=Places
settings.transportation-modes=Transportation Modes
settings.geocoding=Geocoding
settings.integrations=Integrations
settings.logging=Logging
settings.manage.data=Manage Data
settings.job.status=Job Status
settings.import.data=Import Data
@@ -1216,7 +1217,7 @@ settings.geocoding.description=Configure geocoding services to convert coordinat
settings.manage.data.description=Manually trigger data processing and manage your location data
settings.integrations.description=Connect external services and mobile apps to automatically import location data
settings.about.description=View application version and build information
settings.logging.description=Configure logging levels and view logs
memory.new.page.title=New Memory - Reitti
memory.new.title=New Memory
memory.new.back.to.memories=Back to Memories
@@ -1428,4 +1429,31 @@ about.translators.title=Translators
about.projects.title=Open Source Projects
about.projects.visit=Visit Project
about.thankyou.title=Thank You!
about.thankyou.message=Every contribution, no matter how small, helps make Reitti better for everyone. We're grateful for your support and dedication to the open-source community.
about.thankyou.message=Every contribution, no matter how small, helps make Reitti better for everyone. We're grateful for your support and dedication to the open-source community.
# Logging page translations
logging.title=Log Viewer
logging.logger.class=Logger Class
logging.logger.placeholder=Enter the logger class name or leave it empty to configure the root logger
logging.logger.help=Leave empty to configure the root (global) logger
logging.log.name=Logger Name
logging.log.level=Log Level
logging.actions=Actions
logging.level.trace=TRACE
logging.level.debug=DEBUG
logging.level.info=INFO
logging.level.warn=WARN
logging.level.error=ERROR
logging.buffer.size=Buffer Size
logging.buffer.max.size=Max buffer size: {0}
logging.update=Update
logging.configured.loggers=Configured Loggers
logging.remove=Remove
logging.confirm.remove=Are you sure you want to remove this logger configuration?
logging.autoscroll=Auto-scroll to new messages
logging.connecting=Connecting to log stream...
logging.settings.updated=Settings updated successfully
logging.error=Error
logging.connected=Connected to log stream
logging.connection.lost=Error: Connection to log stream lost

View File

@@ -73,7 +73,12 @@
th:classappend="${activeSection == 'integrations'} ? 'active' : ''"
th:title="#{settings.integrations.description}"
th:text="#{settings.integrations}">Integrations</a>
<a href="/settings/logging"
th:if="${isAdmin}"
class="settings-nav-item"
th:classappend="${activeSection == 'logging'} ? 'active' : ''"
th:title="#{settings.logging.description}"
th:text="#{settings.logging}">Logging</a>
<a href="/settings/about"
class="settings-nav-item"
th:classappend="${activeSection == 'about'} ? 'active' : ''"

View File

@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="#{settings.title}">Settings - Reitti</title>
<link rel="icon" th:href="@{/img/logo.svg}">
<link rel="apple-touch-icon" th:href="@{/img/logo.svg}">
<link rel="shortcut icon" type="image/x-icon" th:href="@{/img/logo.svg}">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/lineicons.css">
<script src="/js/htmx.min.js"></script>
<script src="/js/util.js"></script>
<style>
.log-output {
background: #1e1e1e;
color: #d4d4d4;
font-family: 'Courier New', monospace;
font-size: 12px;
padding: 15px;
border-radius: 5px;
height: 600px;
overflow-y: auto;
border: 1px solid #ddd;
}
.log-line {
margin: 0;
padding: 2px 0;
white-space: pre-wrap;
word-break: break-all;
}
.log-line:nth-child(even) {
background-color: rgba(255, 255, 255, 0.02);
}
.log-line.warn {
color: #ffa500;
}
.log-line.error {
color: #ff6b6b;
}
.log-line.fatal {
color: #ff0000;
font-weight: bold;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.autoscroll-control {
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 5px;
}
.logger-name {
font-weight: bold;
font-family: monospace;
}
.logger-level-select {
min-width: 100px;
}
.btn-sm {
padding: 5px 10px;
font-size: 0.875em;
margin-right: 5px;
}
.logger-level-form {
display: flex;
align-items: center;
gap: 10px;
}
</style>
</head>
<body class="settings-page">
<div class="navigation-container" th:replace="~{fragments/main-navigation :: main-navigation('settings')}"></div>
<div class="settings-container">
<div th:replace="~{fragments/settings-navigation :: settings-nav(${activeSection}, ${dataManagementEnabled}, ${isAdmin})}"></div>
<div class="settings-content-area">
<div id="logging" class="settings-section active">
<div th:fragment="logging-content">
<h2 th:text="#{logging.title}">Log Viewer</h2>
<div class="settings-card">
<div class="autoscroll-control">
<input type="checkbox" id="autoscrollToggle" checked>
<label for="autoscrollToggle" th:text="#{logging.autoscroll}">Auto-scroll to new messages</label>
</div>
<div class="log-output" id="logOutput">
<div class="log-line" th:text="#{logging.connecting}">Connecting to log stream...</div>
</div>
</div>
<div id="statusMessage" class="alert alert-success" style="display: none"></div>
<div id="loggingSettingsCard" th:fragment="logging-settings-card" class="settings-card">
<form id="loggingSettingsForm"
hx-post="/settings/logging/update"
hx-target="#loggingSettingsCard"
hx-swap="outerHTML">
<div class="form-group">
<label for="loggerClass" th:text="#{logging.logger.class}">Logger Class:</label>
<input type="text" id="loggerClass" name="logger" class="input-field"
th:placeholder="#{logging.logger.placeholder}">
<small class="form-text" th:text="#{logging.logger.help}">Leave empty to configure the root (global) logger</small>
</div>
<div class="form-group">
<label for="logLevel" th:text="#{logging.log.level}">Log Level:</label>
<select id="logLevel" name="level" class="select-field">
<option value="TRACE" th:text="#{logging.level.trace}" th:selected="${currentLogLevel == 'TRACE'}">TRACE</option>
<option value="DEBUG" th:text="#{logging.level.debug}" th:selected="${currentLogLevel == 'DEBUG'}">DEBUG</option>
<option value="INFO" th:text="#{logging.level.info}" th:selected="${currentLogLevel == 'INFO'}">INFO</option>
<option value="WARN" th:text="#{logging.level.warn}" th:selected="${currentLogLevel == 'WARN'}">WARN</option>
<option value="ERROR" th:text="#{logging.level.error}" th:selected="${currentLogLevel == 'ERROR'}">ERROR</option>
</select>
</div>
<div class="form-group">
<label for="bufferSize" th:text="#{logging.buffer.size}">Buffer Size:</label>
<input type="number" id="bufferSize" name="size" class="input-field"
th:value="${currentBufferSize}"
th:max="${maxBufferSize}"
min="100" step="100">
<small class="form-text" th:text="#{logging.buffer.max.size(${maxBufferSize})}">Max buffer size: 10000</small>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">
<span th:text="#{logging.update}">Update</span>
<span class="htmx-indicator">...</span>
</button>
</div>
</form>
<h3 th:text="#{logging.configured.loggers}">Configured Loggers</h3>
<table>
<thead>
<tr>
<th th:text="#{logging.log.name}">Logger Name</th>
<th th:text="#{logging.log.level}">Log Level</th>
<th th:text="#{logging.actions}">Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="logger : ${configuredLoggers}">
<td>
<span class="logger-name" th:text="${logger.name}">Logger Name</span>
</td>
<td>
<form class="logger-level-form"
hx-post="/settings/logging/update"
hx-target="#loggingSettingsCard"
hx-swap="outerHTML">
<input type="hidden" name="logger" th:value="${logger.name}">
<input type="hidden" name="size" th:value="${currentBufferSize}">
<select class="select-field logger-level-select" name="level">
<option value="TRACE" th:text="#{logging.level.trace}" th:selected="${logger.level == 'TRACE'}">TRACE</option>
<option value="DEBUG" th:text="#{logging.level.debug}" th:selected="${logger.level == 'DEBUG'}">DEBUG</option>
<option value="INFO" th:text="#{logging.level.info}" th:selected="${logger.level == 'INFO'}">INFO</option>
<option value="WARN" th:text="#{logging.level.warn}" th:selected="${logger.level == 'WARN'}">WARN</option>
<option value="ERROR" th:text="#{logging.level.error}" th:selected="${logger.level == 'ERROR'}">ERROR</option>
</select>
<button type="submit" class="btn btn-secondary">
<span th:text="#{logging.update}">Update</span>
<span class="htmx-indicator">...</span>
</button>
</form>
</td>
<td>
<button type="button" class="btn btn-danger"
th:if="${logger.name != 'ROOT'}"
hx-post="/settings/logging/remove"
th:hx-vals="${'{&quot;logger&quot;:&quot;' + logger.name + '&quot;}'}"
hx-target="#loggingSettingsCard"
hx-swap="outerHTML"
th:hx-confirm="#{logging.confirm.remove}">
<span th:text="#{logging.remove}">Remove</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script th:inline="javascript">
window.userSettings = /*[[${userSettings}]]*/ {}
// Set up SSE connection for log streaming
const logOutput = document.getElementById('logOutput');
const autoscrollToggle = document.getElementById('autoscrollToggle');
const eventSource = new EventSource('/settings/logging/stream');
function shouldAutoscroll() {
return autoscrollToggle.checked;
}
function scrollToBottom() {
if (shouldAutoscroll()) {
logOutput.scrollTop = logOutput.scrollHeight;
}
}
function addLogLevelClass(logHtml) {
// Add CSS classes based on log level
if (logHtml.includes('[WARN]')) {
return logHtml.replace('<div class="log-line">', '<div class="log-line warn">');
} else if (logHtml.includes('[ERROR]')) {
return logHtml.replace('<div class="log-line">', '<div class="log-line error">');
} else if (logHtml.includes('[FATAL]')) {
return logHtml.replace('<div class="log-line">', '<div class="log-line fatal">');
}
return logHtml;
}
eventSource.onopen = function() {
logOutput.innerHTML = '<div class="log-line">' + /*[[#{logging.connected}]]*/ 'Connected to log stream' + '</div>';
scrollToBottom();
};
eventSource.addEventListener('log', function(event) {
const coloredLogHtml = addLogLevelClass(event.data);
logOutput.insertAdjacentHTML('beforeend', coloredLogHtml);
scrollToBottom();
});
eventSource.onerror = function(event) {
logOutput.innerHTML += '<div class="log-line error">' + /*[[#{logging.connection.lost}]]*/ 'Error: Connection to log stream lost' + '</div>';
scrollToBottom();
};
// Clean up on page unload
window.addEventListener('beforeunload', function() {
eventSource.close();
});
</script>
</body>
</html>