diff --git a/src/main/java/com/dedicatedcode/reitti/config/LoggingProperties.java b/src/main/java/com/dedicatedcode/reitti/config/LoggingProperties.java new file mode 100644 index 00000000..3f15ae76 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/config/LoggingProperties.java @@ -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; + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java b/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java index 609f8f8c..9e9815e9 100644 --- a/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java +++ b/src/main/java/com/dedicatedcode/reitti/config/SecurityConfig.java @@ -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(), diff --git a/src/main/java/com/dedicatedcode/reitti/controller/settings/LoggingController.java b/src/main/java/com/dedicatedcode/reitti/controller/settings/LoggingController.java new file mode 100644 index 00000000..48c544ea --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/settings/LoggingController.java @@ -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"; + } + } + +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/logging/InMemoryLogAppender.java b/src/main/java/com/dedicatedcode/reitti/service/logging/InMemoryLogAppender.java new file mode 100644 index 00000000..b0c3b961 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/logging/InMemoryLogAppender.java @@ -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 { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + private final List buffer = new CopyOnWriteArrayList<>(); + private final List> 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 getSnapshot() { + return new ArrayList<>(buffer); + } + + public void addListener(Consumer listener) { + listeners.add(listener); + } + + public void removeListener(Consumer 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 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()); + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/logging/LoggingService.java b/src/main/java/com/dedicatedcode/reitti/service/logging/LoggingService.java new file mode 100644 index 00000000..9e9ad1cc --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/logging/LoggingService.java @@ -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 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 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 getAllConfiguredLoggers() { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + List 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 snapshot = getLogSnapshot(); + for (String logLine : snapshot) { + emitter.send(SseEmitter.event() + .name("log") + .data(formatLogLineForHtml(logLine))); + } + } catch (IOException e) { + emitter.completeWithError(e); + return emitter; + } + + Consumer 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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + + return "
" + escaped + "
"; + } + + public record LoggerInfo(String name, String level) { + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cc411c18..aac72f3d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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 diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 0b531f54..bbf780c6 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -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. \ No newline at end of file +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 \ No newline at end of file diff --git a/src/main/resources/templates/fragments/settings-navigation.html b/src/main/resources/templates/fragments/settings-navigation.html index dcb99218..f63e4caf 100644 --- a/src/main/resources/templates/fragments/settings-navigation.html +++ b/src/main/resources/templates/fragments/settings-navigation.html @@ -73,7 +73,12 @@ th:classappend="${activeSection == 'integrations'} ? 'active' : ''" th:title="#{settings.integrations.description}" th:text="#{settings.integrations}">Integrations - + Logging + + + + + Settings - Reitti + + + + + + + + + + + +
+
+
+ +
+
+

Log Viewer

+
+
+ + +
+
+
Connecting to log stream...
+
+
+ + +
+
+
+ + + Leave empty to configure the root (global) logger +
+ +
+ + +
+ +
+ + + Max buffer size: 10000 +
+ +
+ +
+
+

Configured Loggers

+ + + + + + + + + + + + + + + +
Logger NameLog LevelActions
+ Logger Name + +
+ + + + +
+
+ +
+
+ + +
+
+
+
+ + + +