mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 01:17:57 -05:00
549 feature request add logging section (#550)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
|
||||
return "<div class=\"log-line\">" + escaped + "</div>";
|
||||
}
|
||||
|
||||
public record LoggerInfo(String name, String level) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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' : ''"
|
||||
|
||||
258
src/main/resources/templates/settings/logging.html
Normal file
258
src/main/resources/templates/settings/logging.html
Normal 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="${'{"logger":"' + logger.name + '"}'}"
|
||||
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>
|
||||
Reference in New Issue
Block a user