v0.26.3 web viewer improvement

This commit is contained in:
lixen committed 2026-10-05 03:33:24 -04:00
1 parent d745a128a2
commit c00c076d9b
14 files changed
+159 -85

No files matched your search

+4 -1
View File
@@ -220,7 +220,7 @@ func proxySink(t *testing.T, proxies []any, overrides map[string]any) *HTTPSink
// Behind a proxy the sink serves the client library and the enabled pages
// under /auth/, each with its type and the pages' CSP; the viewer learns a
// custom status path from its meta tag.
// custom status path and the level table from its meta tags.
func TestProxyModeServesBrowserFiles(t *testing.T) {
h := proxySink(t, []any{"127.0.0.1"}, map[string]any{"login_page": true, "viewer_page": true, "status_path": "/api/status"})
if !slices.Contains(h.Capabilities(), core.CapProxyTLS) {
@@ -245,6 +245,9 @@ func TestProxyModeServesBrowserFiles(t *testing.T) {
if file == "view" && !bytes.Contains(body, []byte(`content="api/status"`)) {
t.Error("the viewer page does not carry the custom status path")
}
if file == "view" && !bytes.Contains(body, []byte(`"WARNING"`)) {
t.Error("the viewer page does not carry the level table")
}
}
}
+6 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"html"
"net/http"
@@ -13,6 +14,7 @@ import (
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/logwisp/internal/chain"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/netacl"
)
@@ -49,7 +51,8 @@ var webRoutes = []struct {
// webHandlers maps each GET path a browser uses to its handler. Without a
// login (no auth, or mtls: the certificate is the login) the viewer is always
// served; under scram the pages need proxy mode. The viewer learns the status
// path and whether it logs in from meta tags, as the CSP allows no inline script.
// path, whether it logs in and the level table from meta tags, as the CSP
// allows no inline script.
func webHandlers(o *config.HTTPSinkOptions, p *authz.Policy) (map[string]http.Handler, error) {
open := !p.NeedsLogin()
on := map[string]bool{"": open || p.BehindProxy(), "login": o.LoginPage, "view": open || o.ViewerPage}
@@ -68,6 +71,8 @@ func webHandlers(o *config.HTTPSinkOptions, p *authz.Policy) (map[string]http.Ha
if open {
data = bytes.Replace(data, []byte(`name="logwisp-login" content="scram"`), []byte(`name="logwisp-login" content="none"`), 1)
}
levels, _ := json.Marshal(core.Levels)
data = bytes.Replace(data, []byte(`name="logwisp-levels" content=""`), []byte(`name="logwisp-levels" content="`+html.EscapeString(string(levels))+`"`), 1)
}
handlers[chain.AuthPath+"/"+r.name] = serveWebFile(data, webTypes[path.Ext(r.file)])
}
+3 -1
View File
@@ -67,7 +67,8 @@ label {
color: var(--muted);
}
input {
input,
select {
font: inherit;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line);
@@ -95,6 +96,7 @@ progress {
.viewer header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
+3
View File
@@ -6,6 +6,7 @@
<title>LogWisp stream</title>
<meta name="logwisp-status" content="status">
<meta name="logwisp-login" content="scram">
<meta name="logwisp-levels" content="">
<link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<script type="module" src="view.js"></script>
@@ -13,6 +14,8 @@
<body class="viewer">
<header>
<h1>LogWisp</h1>
<input id="search" type="search" placeholder="Search" aria-label="Show lines containing">
<select id="level" aria-label="Lowest level shown"><option value="">All lines</option></select>
<span id="state" role="status">connecting…</span>
<button id="logout" type="button">Sign out</button>
</header>
+40 -2
View File
@@ -13,6 +13,13 @@ const signOut = document.getElementById("logout");
const form = document.getElementById("login");
const progress = document.getElementById("progress");
const status = document.getElementById("status");
const search = document.getElementById("search");
const lowest = document.getElementById("level");
// The server's levels by rising severity, each with the words naming it
const levels = JSON.parse(meta("levels"));
const named = new Map(levels.flatMap(({ level, names }) => names.map((name) => [name, level])));
const rank = new Map(levels.map(({ level }, i) => [level, i]));
for (const { level } of levels) lowest.add(new Option(`${level} and above`, level));
// A page without a login needs no WebCrypto, so plain http works too
const unavailable = open ? "" : loginUnavailable();
@@ -27,10 +34,35 @@ if (fresh) history.replaceState(null, "", location.pathname + location.search);
let stopStream = null;
let timer = null;
function append(line) {
// An entry's level is its first word naming one, as the server reads a line
function levelOf(text) {
for (const [word] of text.matchAll(/\w+/g)) {
const level = named.get(word.toUpperCase());
if (level) return level;
}
return "";
}
// A line shows when it contains the search, in any case, and its entry's level
// reaches the lowest chosen; with none chosen, lines without a level show too
function shown(row) {
const floor = rank.get(lowest.value);
if (floor !== undefined && !(rank.get(row.dataset.level) >= floor)) return false;
const text = search.value.trim().toLowerCase();
return !text || row.textContent.toLowerCase().includes(text);
}
function refilter() {
for (const row of log.children) row.hidden = !shown(row);
log.scrollTop = log.scrollHeight;
}
function append(line, level) {
const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
const row = document.createElement("div");
row.textContent = line;
row.dataset.level = level;
row.hidden = !shown(row);
log.append(row);
while (log.childElementCount > MAX_LINES) log.firstElementChild.remove();
if (atBottom) log.scrollTop = log.scrollHeight;
@@ -73,7 +105,10 @@ function onEvent(type, data) {
stopStream();
retry("server shut down");
}
if (type === "message") data.split("\n").forEach(append);
if (type === "message") {
const level = levelOf(data);
for (const line of data.split("\n")) append(line, level);
}
}
function streamWithCookie(url) {
@@ -129,6 +164,9 @@ function retry(reason) {
timer = setTimeout(connect, 5000);
}
search.addEventListener("input", refilter);
lowest.addEventListener("change", refilter);
form.addEventListener("submit", async (event) => {
event.preventDefault();
const fields = new FormData(form);