mirror of https://github.com/jetkvm/kvm.git
feat(logging): add a simple logging streaming endpoint
This commit is contained in:
parent
58605718d0
commit
d5950f1485
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
||||||
|
../../internal/logging/sse.html
|
|
@ -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
3
web.go
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue