webterm: restore Python-style dashboard search and add gzip

Restore dashboard typeahead behavior to match the Python version with floating results, keyboard navigation, tile highlighting, and Enter-to-open handling.

Add HTTP gzip compression middleware (while excluding WebSocket upgrades) to reduce SVG transfer size, and add a safe make push target that pushes current branch plus tags on HEAD.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-14 19:19:49 +00:00
parent 0361ba41a8
commit 0ca413f10c
2 changed files with 452 additions and 3 deletions
+18 -1
View File
@@ -1,4 +1,4 @@
.PHONY: help install install-dev lint format test race coverage check fuzz build-go build build-fast bundle bundle-watch bundle-clean clean clean-all build-all typecheck bump-patch .PHONY: help install install-dev lint format test race coverage check fuzz build-go build build-fast bundle bundle-watch bundle-clean clean clean-all build-all typecheck bump-patch push
GO_DIR = go GO_DIR = go
STATIC_JS_DIR = go/webterm/static/js STATIC_JS_DIR = go/webterm/static/js
@@ -83,3 +83,20 @@ bump-patch: ## Bump patch version in VERSION and create git tag
git commit -m "Bump version to $$NEW"; \ git commit -m "Bump version to $$NEW"; \
git tag "v$$NEW"; \ git tag "v$$NEW"; \
echo "Bumped version: $$OLD -> $$NEW (tagged v$$NEW)" echo "Bumped version: $$OLD -> $$NEW (tagged v$$NEW)"
push: ## Push current branch and tags pointing at HEAD
@BRANCH=$$(git rev-parse --abbrev-ref HEAD); \
if [ "$$BRANCH" = "HEAD" ]; then \
echo "Detached HEAD; refusing to push"; \
exit 1; \
fi; \
git push origin "$$BRANCH"; \
TAGS=$$(git tag --points-at HEAD); \
if [ -n "$$TAGS" ]; then \
for TAG in $$TAGS; do \
echo "Pushing tag $$TAG"; \
git push origin "$$TAG"; \
done; \
else \
echo "No tags on current commit"; \
fi
+434 -2
View File
@@ -2,6 +2,7 @@ package webterm
import ( import (
"bufio" "bufio"
"compress/gzip"
"context" "context"
"crypto/sha1" "crypto/sha1"
"encoding/json" "encoding/json"
@@ -67,6 +68,11 @@ type loggingResponseWriter struct {
bytes int bytes int
} }
type gzipResponseWriter struct {
http.ResponseWriter
writer *gzip.Writer
}
func (w *loggingResponseWriter) WriteHeader(statusCode int) { func (w *loggingResponseWriter) WriteHeader(statusCode int) {
w.status = statusCode w.status = statusCode
w.ResponseWriter.WriteHeader(statusCode) w.ResponseWriter.WriteHeader(statusCode)
@@ -114,6 +120,41 @@ func (w *loggingResponseWriter) Push(target string, opts *http.PushOptions) erro
return http.ErrNotSupported return http.ErrNotSupported
} }
func (w *gzipResponseWriter) WriteHeader(statusCode int) {
w.Header().Del("Content-Length")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Add("Vary", "Accept-Encoding")
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *gzipResponseWriter) Write(payload []byte) (int, error) {
if w.Header().Get("Content-Encoding") == "" {
w.WriteHeader(http.StatusOK)
}
return w.writer.Write(payload)
}
func (w *gzipResponseWriter) ReadFrom(r io.Reader) (int64, error) {
if w.Header().Get("Content-Encoding") == "" {
w.WriteHeader(http.StatusOK)
}
return io.Copy(w.writer, r)
}
func (w *gzipResponseWriter) Flush() {
_ = w.writer.Flush()
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
if pusher, ok := w.ResponseWriter.(http.Pusher); ok {
return pusher.Push(target, opts)
}
return http.ErrNotSupported
}
type LocalServer struct { type LocalServer struct {
host string host string
port int port int
@@ -369,6 +410,26 @@ func (s *LocalServer) loggingMiddleware(next http.Handler) http.Handler {
}) })
} }
func (s *LocalServer) gzipMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
if strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket") {
next.ServeHTTP(w, r)
return
}
gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
if err != nil {
next.ServeHTTP(w, r)
return
}
defer func() { _ = gz.Close() }()
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, writer: gz}, r)
})
}
func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
routeKey := strings.TrimPrefix(r.URL.Path, "/ws/") routeKey := strings.TrimPrefix(r.URL.Path, "/ws/")
if routeKey == "" { if routeKey == "" {
@@ -755,7 +816,378 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
if s.dockerWatch { if s.dockerWatch {
dockerWatchJS = "true" dockerWatchJS = "true"
} }
html := fmt.Sprintf(`<!DOCTYPE html><html><head><title>Session Dashboard</title><link rel="manifest" href="/static/manifest.json"><meta name="theme-color" content="#0d1117"><link rel="icon" href="/static/icons/webterm-192.png" sizes="192x192"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;margin:16px;background:#0f172a;color:#e2e8f0}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}.tile{background:#1e293b;border:1px solid #334155;border-radius:8px;overflow:hidden;cursor:pointer}.tile-header{padding:10px 12px;font-weight:bold;border-bottom:1px solid #334155;display:flex;justify-content:space-between}.thumb{width:100%%;height:180px;object-fit:contain;background:#0b1220;display:block}.meta{padding:8px 12px;color:#94a3b8;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty{color:#64748b;text-align:center;padding:40px}</style></head><body><h1>Sessions</h1><div id="subtitle"></div><div class="grid" id="grid"></div><script>let tiles=%s;const composeMode=%s;const dockerWatchMode=%s;let cardsBySlug={};const grid=document.getElementById("grid");const subtitle=document.getElementById("subtitle");function openTile(tile){const url='/?route_key='+encodeURIComponent(tile.slug);const target='webterm-'+tile.slug;let win=window.open(url,target);if(!win){window.location.href=url;return}if(typeof win.focus==='function'){win.focus()}}function makeTile(tile){const card=document.createElement('div');card.className='tile';const header=document.createElement('div');header.className='tile-header';header.innerHTML='<span>'+tile.name+'</span>';if(composeMode){const spark=document.createElement('img');spark.width=80;spark.height=16;spark.alt='CPU';header.appendChild(spark);card.sparkline=spark}const img=document.createElement('img');img.className='thumb';img.alt=tile.name;const meta=document.createElement('div');meta.className='meta';meta.textContent=tile.command||'';const body=document.createElement('div');body.appendChild(img);card.appendChild(header);card.appendChild(body);card.appendChild(meta);card.onclick=()=>openTile(tile);card.img=img;return card}function refreshTile(slug){const card=cardsBySlug[slug];if(card){card.img.src='/screenshot.svg?route_key='+encodeURIComponent(slug)+'&_t='+Date.now()}}function refreshSparklines(){if(!composeMode)return;tiles.forEach(tile=>{const card=cardsBySlug[tile.slug];if(card&&card.sparkline){card.sparkline.src='/cpu-sparkline.svg?container='+encodeURIComponent(tile.slug)+'&width=80&height=16&_t='+Date.now()}})}async function refreshTiles(){try{const resp=await fetch('/tiles');const next=await resp.json();const oldSlugs=tiles.map(t=>t.slug).sort().join(',');const newSlugs=next.map(t=>t.slug).sort().join(',');if(oldSlugs!==newSlugs){tiles=next;render()}}catch(_){}}function render(){grid.innerHTML='';cardsBySlug={};if(!tiles.length){grid.innerHTML='<div class="empty">No containers found. Start containers with the webterm-command label.</div>';subtitle.textContent=dockerWatchMode?'Watching for containers with webterm-command label...':'';return}subtitle.textContent='';if(dockerWatchMode){console.log(tiles.length+' container(s) found')};tiles.forEach(tile=>{const card=makeTile(tile);card.img.src='/screenshot.svg?route_key='+encodeURIComponent(tile.slug);grid.appendChild(card);cardsBySlug[tile.slug]=card});refreshSparklines()}let source=null;function startSSE(){if(source)return;source=new EventSource('/events');source.addEventListener('activity',(e)=>{if(e.data==='__dashboard__'){refreshTiles()}else{refreshTile(e.data)}});source.onerror=()=>{source.close();source=null;setTimeout(startSSE,2000)}}render();if(!document.hidden)startSSE();document.addEventListener('visibilitychange',()=>{if(document.hidden){if(source){source.close();source=null}}else startSSE()});if(composeMode){refreshSparklines();setInterval(refreshSparklines,30000)}</script></body></html>`, string(tilesJSON), composeModeJS, dockerWatchJS) html := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<title>Session Dashboard</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#0d1117">
<link rel="icon" href="/static/icons/webterm-192.png" sizes="192x192">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 16px; background: #0f172a; color: #e2e8f0; }
h1 { margin-bottom: 8px; }
.subtitle { color: #64748b; font-size: 14px; margin-bottom: 16px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.tile { background: #1e293b; border: 1px solid #334155; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,0.4); cursor: pointer; transition: border-color 0.15s; }
.tile:hover { border-color: #475569; }
.tile.selected { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.3); }
.tile-header { padding: 10px 12px; font-weight: bold; border-bottom: 1px solid #334155; display: flex; align-items: center; justify-content: space-between; }
.tile-title { display: flex; align-items: center; gap: 8px; }
.sparkline { opacity: 0.9; }
.tile-body { padding: 0; }
.thumb { width: 100%%; height: 180px; object-fit: contain; background: #0b1220; display: block; }
.meta { padding: 8px 12px; color: #94a3b8; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.empty { color: #64748b; text-align: center; padding: 40px; }
.floating-results { position: fixed; top: 50%%; left: 50%%; transform: translate(-50%%, -50%%); width: 400px; max-width: 90vw; max-height: 70vh; overflow-y: auto; background: #1e293b; border: 1px solid #475569; border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); padding: 16px; z-index: 1000; }
.floating-results.hidden { display: none; }
.floating-results .search-header { margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #334155; display: flex; align-items: center; gap: 8px; }
.floating-results .search-query { font-size: 18px; font-weight: bold; color: #3b82f6; }
.floating-results .result-item { display: flex; align-items: center; gap: 12px; padding: 12px; margin: 6px 0; border: 1px solid #334155; border-radius: 6px; cursor: pointer; transition: all 0.15s; }
.floating-results .result-item:hover, .floating-results .result-item.active { background: #334155; border-color: #3b82f6; }
.floating-results .result-thumb { width: 96px; height: 72px; flex: 0 0 auto; border-radius: 4px; border: 1px solid #334155; background: #0b1220; object-fit: contain; }
.floating-results .result-content { display: flex; flex-direction: column; gap: 2px; }
.floating-results .result-title { font-weight: bold; margin-bottom: 4px; }
.floating-results .result-meta { font-size: 12px; color: #94a3b8; }
.floating-results .no-results { color: #64748b; text-align: center; padding: 20px; }
.key-indicator { position: fixed; bottom: 16px; left: 16px; display: flex; gap: 4px; z-index: 1000; }
.key-box { display: inline-flex; align-items: center; justify-content: center; background: #334155; color: #e2e8f0; font-size: 12px; font-weight: bold; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); opacity: 1; transition: opacity 0.3s; }
.key-box.square { width: 28px; height: 28px; }
.key-box.rectangle { padding: 4px 8px; }
.key-box.fade-out { opacity: 0; }
.help-hint { position: fixed; bottom: 16px; right: 16px; color: #64748b; font-size: 12px; }
</style>
</head>
<body>
<h1>Sessions</h1>
<div class="subtitle" id="subtitle"></div>
<div class="grid" id="grid"></div>
<div class="floating-results hidden" id="floating-results"></div>
<div class="key-indicator" id="key-indicator"></div>
<div class="help-hint">Type to search • ↑↓ to navigate • Enter to open • Esc to clear</div>
<script>
let tiles = %s;
const composeMode = %s;
const dockerWatchMode = %s;
let cardsBySlug = {};
let searchQuery = '';
let activeResultIndex = -1;
let filteredResults = [];
const floatingResultsEl = document.getElementById('floating-results');
const keyIndicatorEl = document.getElementById('key-indicator');
const thumbnailCache = {};
const THUMBNAIL_TTL_MS = 5000;
const grid = document.getElementById('grid');
const subtitle = document.getElementById('subtitle');
function makeTile(tile) {
const card = document.createElement('div');
card.className = 'tile';
const header = document.createElement('div');
header.className = 'tile-header';
const titleSpan = document.createElement('div');
titleSpan.className = 'tile-title';
titleSpan.innerHTML = '<span>' + tile.name + '</span>';
header.appendChild(titleSpan);
if (composeMode) {
const sparkline = document.createElement('img');
sparkline.className = 'sparkline';
sparkline.width = 80;
sparkline.height = 16;
sparkline.alt = 'CPU';
header.appendChild(sparkline);
card.sparkline = sparkline;
}
const body = document.createElement('div');
body.className = 'tile-body';
const img = document.createElement('img');
img.className = 'thumb';
img.alt = tile.name;
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = tile.command || '';
meta.title = tile.command || '';
body.appendChild(img);
card.appendChild(header);
card.appendChild(body);
card.appendChild(meta);
card.onclick = () => openTile(tile);
card.img = img;
return card;
}
function normalizeText(value) {
return (value || '').toString().toLowerCase();
}
function getTileTitle(tile) {
return tile.name || tile.slug || 'Unknown';
}
function getTileCommand(tile) {
return tile.command || '';
}
function getThumbnailSrc(tile) {
const slug = tile.slug || '';
if (!slug) return '';
const now = Date.now();
const existing = thumbnailCache[slug];
if (!existing || (now - existing.updatedAt) > THUMBNAIL_TTL_MS) {
const src = '/screenshot.svg?route_key=' + encodeURIComponent(slug) + '&_t=' + now;
thumbnailCache[slug] = { src, updatedAt: now };
return src;
}
return existing.src;
}
function updateTileSelection() {
Object.values(cardsBySlug).forEach((c) => c.classList.remove('selected'));
if (filteredResults.length > 0 && activeResultIndex >= 0) {
const selected = filteredResults[activeResultIndex];
if (selected && selected.slug) {
const card = cardsBySlug[selected.slug];
if (card) card.classList.add('selected');
}
}
}
function renderFloatingResults() {
floatingResultsEl.innerHTML = '';
if (searchQuery === '') {
floatingResultsEl.classList.add('hidden');
activeResultIndex = -1;
filteredResults = [];
updateTileSelection();
return;
}
const query = normalizeText(searchQuery);
filteredResults = tiles.filter((t) => {
if (!t) return false;
const name = normalizeText(t.name);
const command = normalizeText(t.command);
const slug = normalizeText(t.slug);
return name.includes(query) || command.includes(query) || slug.includes(query);
});
const header = document.createElement('div');
header.className = 'search-header';
header.innerHTML = '<span>Search:</span><span class="search-query">' + searchQuery + '</span>';
floatingResultsEl.appendChild(header);
if (filteredResults.length === 0) {
const noResults = document.createElement('div');
noResults.className = 'no-results';
noResults.textContent = 'No matches found';
floatingResultsEl.appendChild(noResults);
} else {
if (activeResultIndex < 0 || activeResultIndex >= filteredResults.length) {
activeResultIndex = 0;
}
filteredResults.forEach((tile, index) => {
const item = document.createElement('div');
item.className = 'result-item' + (index === activeResultIndex ? ' active' : '');
const thumb = document.createElement('img');
thumb.className = 'result-thumb';
const title = getTileTitle(tile);
const command = getTileCommand(tile);
const thumbSrc = getThumbnailSrc(tile);
thumb.alt = title;
if (thumbSrc) {
thumb.src = thumbSrc;
} else {
thumb.style.display = 'none';
}
const content = document.createElement('div');
content.className = 'result-content';
content.innerHTML = '<div class="result-title">' + title + '</div><div class="result-meta">' + command + '</div>';
item.appendChild(thumb);
item.appendChild(content);
item.onclick = () => openTile(tile);
floatingResultsEl.appendChild(item);
});
}
floatingResultsEl.classList.remove('hidden');
updateTileSelection();
}
function showKeyIndicator(key) {
const arrowKeyMap = { ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓' };
const keyDisplay = arrowKeyMap[key] || key;
const keyBox = document.createElement('div');
keyBox.className = 'key-box ' + (key.length > 1 ? 'rectangle' : 'square');
keyBox.textContent = keyDisplay;
keyIndicatorEl.appendChild(keyBox);
setTimeout(() => {
keyBox.classList.add('fade-out');
setTimeout(() => keyBox.remove(), 300);
}, 1500);
}
function openTile(tile) {
if (!tile || !tile.slug) return;
const url = '/?route_key=' + encodeURIComponent(tile.slug);
const target = 'webterm-' + tile.slug;
let win = window.open(url, target);
if (!win) {
window.location.href = url;
return;
}
if (win.closed) {
win = window.open(url, target);
}
if (win && typeof win.focus === 'function') {
win.focus();
}
searchQuery = '';
activeResultIndex = -1;
renderFloatingResults();
}
function handleKeydown(event) {
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return;
showKeyIndicator(event.key);
if (event.key === 'Escape') {
searchQuery = '';
activeResultIndex = -1;
renderFloatingResults();
return;
}
if (event.key === 'Backspace') {
searchQuery = searchQuery.slice(0, -1);
renderFloatingResults();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (filteredResults.length > 0) {
activeResultIndex = (activeResultIndex - 1 + filteredResults.length) %% filteredResults.length;
renderFloatingResults();
}
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
if (filteredResults.length > 0) {
activeResultIndex = (activeResultIndex + 1) %% filteredResults.length;
renderFloatingResults();
}
return;
}
if (event.key === 'Enter') {
if (filteredResults.length > 0 && activeResultIndex >= 0) {
openTile(filteredResults[activeResultIndex]);
} else if (filteredResults.length === 1) {
openTile(filteredResults[0]);
}
return;
}
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
searchQuery += event.key.toLowerCase();
renderFloatingResults();
}
}
document.addEventListener('keydown', handleKeydown);
function refreshTile(slug) {
const card = cardsBySlug[slug];
if (!card) return;
card.img.src = '/screenshot.svg?route_key=' + encodeURIComponent(slug) + '&_t=' + Date.now();
}
function refreshAll() {
for (const tile of tiles) {
const card = cardsBySlug[tile.slug];
if (card) card.img.src = '/screenshot.svg?route_key=' + encodeURIComponent(tile.slug);
}
}
async function refreshTilesList() {
try {
const resp = await fetch('/tiles');
const newTiles = await resp.json();
const oldSlugs = tiles.map((t) => t.slug).sort().join(',');
const newSlugs = newTiles.map((t) => t.slug).sort().join(',');
if (oldSlugs !== newSlugs) {
tiles = newTiles;
renderTiles();
}
} catch (_) {}
}
function refreshSparklines() {
if (!composeMode) return;
for (const tile of tiles) {
const card = cardsBySlug[tile.slug];
if (card && card.sparkline) {
card.sparkline.src = '/cpu-sparkline.svg?container=' + encodeURIComponent(tile.slug) + '&width=80&height=16&_t=' + Date.now();
}
}
}
function renderTiles() {
grid.innerHTML = '';
cardsBySlug = {};
if (tiles.length === 0) {
grid.innerHTML = '<div class="empty">No containers found. Start containers with the webterm-command label.</div>';
subtitle.textContent = dockerWatchMode ? 'Watching for containers with webterm-command label...' : '';
return;
}
subtitle.textContent = '';
if (dockerWatchMode) {
console.log(tiles.length + ' container(s) found');
}
for (const tile of tiles) {
const card = makeTile(tile);
grid.appendChild(card);
cardsBySlug[tile.slug] = card;
}
refreshAll();
renderFloatingResults();
refreshSparklines();
}
let source = null;
function startSSE() {
if (source) return;
source = new EventSource('/events');
source.addEventListener('activity', (e) => {
if (e.data === '__dashboard__') {
refreshTilesList();
} else {
refreshTile(e.data);
}
});
source.onerror = () => {
source.close();
source = null;
setTimeout(startSSE, 2000);
};
}
renderTiles();
if (!document.hidden) startSSE();
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (source) {
source.close();
source = null;
}
} else {
startSSE();
}
});
if (composeMode) {
refreshSparklines();
setInterval(refreshSparklines, 30000);
}
</script>
</body>
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS)
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
_, _ = io.WriteString(w, html) _, _ = io.WriteString(w, html)
return return
@@ -897,7 +1329,7 @@ func (s *LocalServer) Handler() http.Handler {
if strings.TrimSpace(s.staticPath) != "" { if strings.TrimSpace(s.staticPath) != "" {
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath)))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath))))
} }
return s.loggingMiddleware(mux) return s.loggingMiddleware(s.gzipMiddleware(mux))
} }
func (s *LocalServer) Run(ctx context.Context) error { func (s *LocalServer) Run(ctx context.Context) error {