feat(logging): add a simple logging streaming endpoint

This commit is contained in:
Siyuan Miao 2025-04-15 02:24:59 +02:00
parent 58605718d0
commit d5950f1485
6 changed files with 468 additions and 1 deletions

View File

@ -35,7 +35,12 @@ func (w *logOutput) Write(p []byte) (n int, err error) {
defer w.mu.Unlock() defer w.mu.Unlock()
// TODO: write to file or syslog // TODO: write to file or syslog
if sseServer != nil {
// use a goroutine to avoid blocking the Write method
go func() {
sseServer.Message <- string(p)
}()
}
return len(p), nil return len(p), nil
} }

138
internal/logging/sse.go Normal file
View File

@ -0,0 +1,138 @@
package logging
import (
"embed"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
//go:embed sse.html
var sseHTML embed.FS
type sseEvent struct {
Message chan string
NewClients chan chan string
ClosedClients chan chan string
TotalClients map[chan string]bool
}
// New event messages are broadcast to all registered client connection channels
type sseClientChan chan string
var (
sseServer *sseEvent
sseLogger *zerolog.Logger
)
func init() {
sseServer = newSseServer()
sseLogger = GetSubsystemLogger("sse")
}
// Initialize event and Start procnteessing requests
func newSseServer() (event *sseEvent) {
event = &sseEvent{
Message: make(chan string),
NewClients: make(chan chan string),
ClosedClients: make(chan chan string),
TotalClients: make(map[chan string]bool),
}
go event.listen()
return
}
// It Listens all incoming requests from clients.
// Handles addition and removal of clients and broadcast messages to clients.
func (stream *sseEvent) listen() {
for {
select {
// Add new available client
case client := <-stream.NewClients:
stream.TotalClients[client] = true
sseLogger.Info().
Int("total_clients", len(stream.TotalClients)).
Msg("new client connected")
// Remove closed client
case client := <-stream.ClosedClients:
delete(stream.TotalClients, client)
close(client)
sseLogger.Info().Int("total_clients", len(stream.TotalClients)).Msg("client disconnected")
// Broadcast message to client
case eventMsg := <-stream.Message:
for clientMessageChan := range stream.TotalClients {
select {
case clientMessageChan <- eventMsg:
// Message sent successfully
default:
// Failed to send, dropping message
}
}
}
}
}
func (stream *sseEvent) serveHTTP() gin.HandlerFunc {
return func(c *gin.Context) {
clientChan := make(sseClientChan)
stream.NewClients <- clientChan
go func() {
<-c.Writer.CloseNotify()
for range clientChan {
}
stream.ClosedClients <- clientChan
}()
c.Set("clientChan", clientChan)
c.Next()
}
}
func sseHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "GET" && c.NegotiateFormat(gin.MIMEHTML) == gin.MIMEHTML {
c.FileFromFS("/sse.html", http.FS(sseHTML))
c.Status(http.StatusOK)
c.Abort()
return
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Transfer-Encoding", "chunked")
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Next()
}
}
func AttachSSEHandler(router *gin.RouterGroup) {
router.StaticFS("/log-stream", http.FS(sseHTML))
router.GET("/log-stream", sseHeadersMiddleware(), sseServer.serveHTTP(), func(c *gin.Context) {
v, ok := c.Get("clientChan")
if !ok {
return
}
clientChan, ok := v.(sseClientChan)
if !ok {
return
}
c.Stream(func(w io.Writer) bool {
if msg, ok := <-clientChan; ok {
c.SSEvent("message", msg)
return true
}
return false
})
})
}

319
internal/logging/sse.html Normal file
View File

@ -0,0 +1,319 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Server Sent Event</title>
<style>
.main-container {
display: flex;
flex-direction: column;
gap: 10px;
font-family: 'Hack', monospace;
font-size: 12px;
}
#loading {
font-style: italic;
}
.log-entry {
font-size: 12px;
line-height: 1.2;
}
.log-entry > span {
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
margin-right: 10px;
}
.log-entry > span:last-child {
margin-right: 0;
}
.log-entry.log-entry-trace .log-level {
color: blue;
}
.log-entry.log-entry-debug .log-level {
color: gray;
}
.log-entry.log-entry-info .log-level {
color: green;
}
.log-entry.log-entry-warn .log-level {
color: yellow;
}
.log-entry.log-entry-error .log-level,
.log-entry.log-entry-fatal .log-level,
.log-entry.log-entry-panic .log-level {
color: red;
}
.log-entry.log-entry-info .log-message,
.log-entry.log-entry-warn .log-message,
.log-entry.log-entry-error .log-message,
.log-entry.log-entry-fatal .log-message,
.log-entry.log-entry-panic .log-message {
font-weight: bold;
}
.log-timestamp {
color: #666;
min-width: 150px;
}
.log-level {
font-size: 12px;
min-width: 50px;
}
.log-scope {
font-size: 12px;
min-width: 40px;
}
.log-component {
font-size: 12px;
min-width: 80px;
}
.log-message {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 500px;
}
.log-extras {
color: #000;
}
.log-extras .log-extras-header {
font-weight: bold;
color:cornflowerblue;
}
</style>
</head>
<body>
<div class="main-container">
<div id="header">
<span id="loading">
Connecting to log stream...
</span>
<span id="stats">
</span>
</div>
<div id="event-data">
</div>
</div>
</body>
<script>
class LogStream {
constructor(url, eventDataElement, loadingElement, statsElement) {
this.url = url;
this.eventDataElement = eventDataElement;
this.loadingElement = loadingElement;
this.statsElement = statsElement;
this.stream = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000; // Start with 1 second
this.maxReconnectDelay = 30000; // Max 30 seconds
this.isConnecting = false;
this.totalMessages = 0;
this.connect();
}
connect() {
if (this.isConnecting) return;
this.isConnecting = true;
this.loadingElement.innerText = "Connecting to log stream...";
this.stream = new EventSource(this.url);
this.stream.onopen = () => {
this.isConnecting = false;
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
this.loadingElement.innerText = "Log stream connected.";
this.totalMessages = 0;
this.totalBytes = 0;
};
this.stream.onmessage = (event) => {
this.totalBytes += event.data.length;
this.totalMessages++;
const data = JSON.parse(event.data);
this.addLogEntry(data);
this.updateStats();
};
this.stream.onerror = () => {
this.isConnecting = false;
this.loadingElement.innerText = "Log stream disconnected.";
this.stream.close();
this.handleReconnect();
};
}
updateStats() {
this.statsElement.innerHTML = `Messages: <strong>${this.totalMessages}</strong>, Bytes: <strong>${this.totalBytes}</strong> `;
}
handleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.loadingElement.innerText = "Failed to reconnect after multiple attempts";
return;
}
this.reconnectAttempts++;
this.reconnectDelay = Math.min(this.reconnectDelay * 1, this.maxReconnectDelay);
this.loadingElement.innerText = `Reconnecting in ${this.reconnectDelay/1000} seconds... (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
addLogEntry(data) {
const el = document.createElement("div");
el.className = "log-entry log-entry-" + data.level;
const timestamp = document.createElement("span");
timestamp.className = "log-timestamp";
timestamp.innerText = data.time;
el.appendChild(timestamp);
const level = document.createElement("span");
level.className = "log-level";
level.innerText = this.shortLogLevel(data.level);
el.appendChild(level);
const scope = document.createElement("span");
scope.className = "log-scope";
scope.innerText = data.scope;
el.appendChild(scope);
const component = document.createElement("span");
component.className = "log-component";
component.innerText = data.component;
el.appendChild(component);
const message = document.createElement("span");
message.className = "log-message";
message.innerText = data.message;
el.appendChild(message);
this.addLogExtras(el, data);
this.eventDataElement.appendChild(el);
window.scrollTo(0, document.body.scrollHeight);
}
shortLogLevel(level) {
switch (level) {
case "trace":
return "TRC";
case "debug":
return "DBG";
case "info":
return "INF";
case "warn":
return "WRN";
case "error":
return "ERR";
case "fatal":
return "FTL";
case "panic":
return "PNC";
default:
return level;
}
}
addLogExtras(el, data) {
const excludeKeys = [
"timestamp",
"time",
"level",
"scope",
"component",
"message",
];
const extras = {};
for (const key in data) {
if (excludeKeys.includes(key)) {
continue;
}
extras[key] = data[key];
}
for (const key in extras) {
const extra = document.createElement("span");
extra.className = "log-extras log-extras-" + key;
const extraKey = document.createElement("span");
extraKey.className = "log-extras-header";
extraKey.innerText = key + '=';
extra.appendChild(extraKey);
const extraValue = document.createElement("span");
extraValue.className = "log-extras-value";
let value = extras[key];
if (typeof value === 'object') {
value = JSON.stringify(value);
}
extraValue.innerText = value;
extra.appendChild(extraValue);
el.appendChild(extra);
}
}
disconnect() {
if (this.stream) {
this.stream.close();
this.stream = null;
}
}
}
// Initialize the log stream when the page loads
document.addEventListener('DOMContentLoaded', () => {
const logStream = new LogStream(
"/developer/log-stream",
document.getElementById("event-data"),
document.getElementById("loading"),
document.getElementById("stats"),
);
// Clean up when the page is unloaded
window.addEventListener('beforeunload', () => {
logStream.disconnect();
});
});
</script>
</html>

1
ui/public/sse.html Symbolic link
View File

@ -0,0 +1 @@
../../internal/logging/sse.html

View File

@ -26,6 +26,7 @@ export default defineConfig(({ mode, command }) => {
"/auth": JETKVM_PROXY_URL, "/auth": JETKVM_PROXY_URL,
"/storage": JETKVM_PROXY_URL, "/storage": JETKVM_PROXY_URL,
"/cloud": JETKVM_PROXY_URL, "/cloud": JETKVM_PROXY_URL,
"/developer": JETKVM_PROXY_URL,
} }
: undefined, : undefined,
}, },

3
web.go
View File

@ -19,6 +19,7 @@ import (
gin_logger "github.com/gin-contrib/logger" gin_logger "github.com/gin-contrib/logger"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jetkvm/kvm/internal/logging"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
@ -121,6 +122,8 @@ func setupRouter() *gin.Engine {
developerModeRouter.GET("/pprof/heap", gin.WrapH(pprof.Handler("heap"))) developerModeRouter.GET("/pprof/heap", gin.WrapH(pprof.Handler("heap")))
developerModeRouter.GET("/pprof/mutex", gin.WrapH(pprof.Handler("mutex"))) developerModeRouter.GET("/pprof/mutex", gin.WrapH(pprof.Handler("mutex")))
developerModeRouter.GET("/pprof/threadcreate", gin.WrapH(pprof.Handler("threadcreate"))) developerModeRouter.GET("/pprof/threadcreate", gin.WrapH(pprof.Handler("threadcreate")))
logging.AttachSSEHandler(developerModeRouter)
} }
// Protected routes (allows both password and noPassword modes) // Protected routes (allows both password and noPassword modes)