Refactor UI (#33)
- add html to / instead of 404 - add client side regex to /logs
This commit is contained in:
14
proxy/html/index.html
Normal file
14
proxy/html/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>llama-swap</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>llama-swap</h1>
|
||||||
|
<p>
|
||||||
|
<a href="/logs">view logs</a> | <a href="/upstream">configured models</a> | <a href="https://github.com/mostlygeek/llama-swap">github</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -12,42 +12,134 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
font-family: "Courier New", Courier, monospace;
|
font-family: "Courier New", Courier, monospace;
|
||||||
}
|
}
|
||||||
|
#log-controls {
|
||||||
|
margin: 0.5em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between; /* Spaces out elements evenly */
|
||||||
|
}
|
||||||
|
#log-controls input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
#log-controls input:focus {
|
||||||
|
outline: none; /* Ensures no outline is shown when the input is focused */
|
||||||
|
}
|
||||||
#log-stream {
|
#log-stream {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 1em;
|
margin: 0.5em;
|
||||||
padding: 10px;
|
padding: 1em;
|
||||||
background: #f4f4f4;
|
background: #f4f4f4;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap; /* Ensures line wrapping */
|
white-space: pre-wrap; /* Ensures line wrapping */
|
||||||
word-wrap: break-word; /* Ensures long words wrap */
|
word-wrap: break-word; /* Ensures long words wrap */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.regex-error {
|
||||||
|
background-color: #ff0000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-stream {
|
||||||
|
background: #444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-controls input {
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-controls button {
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #777;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<pre id="log-stream">Waiting for logs...
|
<pre id="log-stream">Waiting for logs...</pre>
|
||||||
</pre>
|
<div id="log-controls">
|
||||||
|
<input type="text" id="filter-input" placeholder="regex filter">
|
||||||
|
<button id="clear-button">clear</button>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
// Establish an EventSource connection to the SSE endpoint
|
const logStream = document.getElementById('log-stream');
|
||||||
if (typeof(EventSource) !== "undefined") {
|
const filterInput = document.getElementById('filter-input');
|
||||||
const eventSource = new EventSource("/logs/streamSSE");
|
var logData = "";
|
||||||
|
let regexFilter = null;
|
||||||
|
|
||||||
eventSource.onmessage = function(event) {
|
function setupEventSource() {
|
||||||
// Append the new log message to the <pre> element
|
if (typeof(EventSource) !== "undefined") {
|
||||||
const logStream = document.getElementById('log-stream');
|
const eventSource = new EventSource("/logs/streamSSE");
|
||||||
|
|
||||||
logStream.textContent += event.data;
|
eventSource.onmessage = function(event) {
|
||||||
|
logData += event.data;
|
||||||
|
render()
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-scroll to the bottom
|
eventSource.onerror = function(err) {
|
||||||
logStream.scrollTop = logStream.scrollHeight;
|
logData = "EventSource failed: " + err.message;
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
eventSource.onerror = function(err) {
|
logData = "SSE Not supported by this browser."
|
||||||
console.error("EventSource failed:", err);
|
}
|
||||||
};
|
|
||||||
} else {
|
|
||||||
console.error("SSE not supported by this browser.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// poor-ai's react ¯\_(ツ)_/¯
|
||||||
|
function render() {
|
||||||
|
if (regexFilter) {
|
||||||
|
const lines = logData.split('\n');
|
||||||
|
const filteredLines = lines.filter(line => {
|
||||||
|
return regexFilter === null || regexFilter.test(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredLines.length > 0) {
|
||||||
|
logStream.textContent = filteredLines.join('\n') + '\n';
|
||||||
|
} else {
|
||||||
|
logStream.textContent = "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logStream.textContent = logData;
|
||||||
|
}
|
||||||
|
|
||||||
|
logStream.scrollTop = logStream.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilter() {
|
||||||
|
const pattern = filterInput.value.trim();
|
||||||
|
filterInput.classList.remove('regex-error');
|
||||||
|
if (pattern) {
|
||||||
|
try {
|
||||||
|
regexFilter = new RegExp(pattern);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Invalid regex pattern:", e);
|
||||||
|
regexFilter = null;
|
||||||
|
filterInput.classList.add('regex-error');
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
regexFilter = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
filterInput.addEventListener('input', updateFilter);
|
||||||
|
document.getElementById('clear-button').addEventListener('click', () => {
|
||||||
|
filterInput.value = "";
|
||||||
|
regexFilter = null;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
setupEventSource();
|
||||||
|
updateFilter();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
10
proxy/html_files.go
Normal file
10
proxy/html_files.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed html
|
||||||
|
var htmlFiles embed.FS
|
||||||
|
|
||||||
|
func getHTMLFile(path string) ([]byte, error) {
|
||||||
|
return htmlFiles.ReadFile("html/" + path)
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package proxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -20,15 +19,6 @@ const (
|
|||||||
PROFILE_SPLIT_CHAR = ":"
|
PROFILE_SPLIT_CHAR = ":"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed html/favicon.ico
|
|
||||||
var faviconData []byte
|
|
||||||
|
|
||||||
//go:embed html/logs.html
|
|
||||||
var logsHTML []byte
|
|
||||||
|
|
||||||
// make sure embed is kept there by the IDE auto-package importer
|
|
||||||
var _ = embed.FS{}
|
|
||||||
|
|
||||||
type ProxyManager struct {
|
type ProxyManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
|
|
||||||
@@ -98,8 +88,29 @@ func New(config *Config) *ProxyManager {
|
|||||||
pm.ginEngine.GET("/upstream", pm.upstreamIndex)
|
pm.ginEngine.GET("/upstream", pm.upstreamIndex)
|
||||||
pm.ginEngine.Any("/upstream/:model_id/*upstreamPath", pm.proxyToUpstream)
|
pm.ginEngine.Any("/upstream/:model_id/*upstreamPath", pm.proxyToUpstream)
|
||||||
|
|
||||||
|
pm.ginEngine.GET("/", func(c *gin.Context) {
|
||||||
|
// Set the Content-Type header to text/html
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
|
||||||
|
// Write the embedded HTML content to the response
|
||||||
|
htmlData, err := getHTMLFile("index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = c.Writer.Write(htmlData)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, fmt.Sprintf("failed to write response: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
pm.ginEngine.GET("/favicon.ico", func(c *gin.Context) {
|
pm.ginEngine.GET("/favicon.ico", func(c *gin.Context) {
|
||||||
c.Data(http.StatusOK, "image/x-icon", faviconData)
|
if data, err := getHTMLFile("favicon.ico"); err == nil {
|
||||||
|
c.Data(http.StatusOK, "image/x-icon", data)
|
||||||
|
} else {
|
||||||
|
c.String(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Disable console color for testing
|
// Disable console color for testing
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ func (pm *ProxyManager) sendLogsHandlers(c *gin.Context) {
|
|||||||
c.Header("Content-Type", "text/html")
|
c.Header("Content-Type", "text/html")
|
||||||
|
|
||||||
// Write the embedded HTML content to the response
|
// Write the embedded HTML content to the response
|
||||||
_, err := c.Writer.Write(logsHTML)
|
logsHTML, err := getHTMLFile("logs.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to write response: %v", err))
|
c.String(http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = c.Writer.Write(logsHTML)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, fmt.Sprintf("failed to write response: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -43,7 +48,7 @@ func (pm *ProxyManager) streamLogsHandler(c *gin.Context) {
|
|||||||
notify := c.Request.Context().Done()
|
notify := c.Request.Context().Done()
|
||||||
flusher, ok := c.Writer.(http.Flusher)
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("Streaming unsupported"))
|
c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("streaming unsupported"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +58,7 @@ func (pm *ProxyManager) streamLogsHandler(c *gin.Context) {
|
|||||||
if !skipHistory {
|
if !skipHistory {
|
||||||
history := pm.logMonitor.GetHistory()
|
history := pm.logMonitor.GetHistory()
|
||||||
if len(history) != 0 {
|
if len(history) != 0 {
|
||||||
_, err := c.Writer.Write(history)
|
c.Writer.Write(history)
|
||||||
if err != nil {
|
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ func (pm *ProxyManager) streamLogsHandler(c *gin.Context) {
|
|||||||
case msg := <-ch:
|
case msg := <-ch:
|
||||||
_, err := c.Writer.Write(msg)
|
_, err := c.Writer.Write(msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithError(http.StatusInternalServerError, err)
|
// just break the loop if we can't write for some reason
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
|
|||||||
Reference in New Issue
Block a user