Skip to content
Merged
518 changes: 518 additions & 0 deletions docs/superpowers/plans/2026-06-29-logs-viewer-enhancements.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Logs Viewer Enhancements + Responsive Layout — Design Spec

**Date:** 2026-06-29
**Branch:** `logs-viewer-enhancements` (off `develop`)
**Status:** Approved design, pending spec review → implementation plan
**Builds on:** the 1.4.0 UI redesign ([[daeploy-ui-redesign]]); same FastAPI + Dash + self-contained-assets stack.

## Goal

Make the redesigned UI use the screen better and turn the logs view into a real log console:

- **A. Responsive logs view** — fills the viewport (height + width) instead of a fixed `60vh` / `1180px` card.
- **B. Responsive dashboard** — uses much more of the screen instead of a `1180px` centered column.
- **C. Export logs** — download a service's (or the manager's) full log as a file.
- **D. Search logs** — find text across the full log, filtered to matching lines.

## Constraints

- **Reuse the shared `manager/templates/logs.html`** — it already serves both per-service (`/services/~logs/view`) and manager (`/logs/view`) logs, so both get every feature here.
- **No backend changes.** The existing endpoints already support what's needed: `/services/~logs?...&tail=all&follow=false` and `/logs/stream?tail=all&follow=false` return the full log. Export and search are client-side fetches against these.
- **Self-contained** (no CDNs), consistent with the redesign tokens/theme.
- **Preserve the live tail** — streaming + the Follow/auto-scroll/jump behavior stay; the new features layer on top.

## A. Responsive logs view

- Drop `.page { max-width:1180px }` for the logs page → full width with modest side padding.
- Lay the page out as a **flex column filling the viewport**: top bar + `.logs-head` toolbar take natural height; `.console` gets `flex:1` (and `min-height:0`) so it grows/shrinks with the window and screen resolution. Remove the fixed `height:60vh`.
- Buffer cap stays for the *live tail* (keep DOM light); search/export bypass it by fetching the full log.

## B. Responsive dashboard

- In `manager/assets/dashboard_styles.css`, change `.page { max-width:1180px }` → `max-width:1600px` (still centered, generous side padding). The services/notifications `.grid` then uses far more width. (Decision: capped 1600px centered, not fully fluid — avoids absurd line lengths on ultrawide.)

## C. Export logs to a file

- An **Export** button in the logs toolbar (`.logs-tools`).
- On click: fetch the **full** log (`full_url`, i.e. the endpoint with `tail=all&follow=false`), build a `Blob`, and trigger a download.
- **Filename:** `‹title›_‹subtitle›_‹UTC-timestamp›.log` for services (e.g. `status_code_v0.1.0_20260629-153012.log`); `manager_‹UTC-timestamp›.log` for manager logs. Plain text, `.log`.
- Pure client-side (`fetch` → `Blob` → object URL → `<a download>`). Works on both views.

## D. Search (filter to matches)

- A **search input** in the toolbar, with a **match-count** label.
- On input (debounced ~250 ms) with a non-empty term: fetch the full log once (`full_url`), render **only lines containing the term** (case-insensitive substring), **highlight** the matched substring, and show the count (e.g. `37 matches` / `No matches`).
- **Follow auto-pauses** while a search is active (you're viewing history, not the tail).
- **Clearing** the box restores the **live tail** by clearing the console and restarting the live-tail stream (re-fetch `stream_url`, `tail=200&follow=true`), with Follow re-enabled.
- Highlighting uses DOM text nodes + a `<mark>`-style span (never `innerHTML` on log content — XSS-safe, consistent with the existing `textContent` rendering).
- No regex / no case toggle for now (YAGNI); case-insensitive substring only.

## Toolbar layout

`[🔍 search…] [37 matches] · [Export] · [● Live] [Follow ▢]` — search + count on the left of the tools group, Export next, then the existing Live indicator + Follow toggle.

## Implementation outline (for the plan)

1. **Routes** (`manager/routers/service_api.py::service_logs_view`, `manager/routers/logging_api.py::manager_logs_view`): pass one extra context value, **`full_url`** (the same endpoint as `stream_url` but `tail=all&follow=false`), e.g. service `/services/~logs?name=..&version=..&tail=all`; manager `/logs/stream?tail=all`.
2. **`manager/templates/logs.html`**: add the search input + match-count + Export button to `.logs-tools`; CSS for the full-bleed flex layout, the search box, and `<mark>` highlight; JS for export (fetch full → download), search (fetch full → filter/highlight/count, pause follow), and clear-search (resume tail).
3. **`manager/assets/dashboard_styles.css`**: `.page` max-width 1180 → 1600.
4. **Tests** (`tests/manager_test/test_ui_redesign.py`): assert `logs.html` has the search input, match-count, and Export elements; assert the view routes include `full_url` (tail=all); assert the logs `.page` is no longer capped at 1180 and the dashboard `.page` is 1600.
5. **Verify** live in the running manager: resize (console fills viewport), export downloads a `.log`, search filters with a count, clear resumes the tail; dashboard uses the wider layout.

## Risks / notes

- Fetching `tail=all` on a very large log transfers the whole thing for export/search — acceptable and expected per the "full history" decision; the live tail still uses `tail=200`.
- Search refetches the full log on each new term (debounced); fine for typical logs. Could cache the fetched full log per session as a later optimization (not now).
- This is a separate feature branch off `develop`; ships in a later release (e.g. 1.4.1 / 1.5.0), independent of the in-flight 1.4.0 PyPI publish.
2 changes: 1 addition & 1 deletion manager/assets/dashboard_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ a{color:inherit;text-decoration:none;}
.act.danger:hover{color:var(--crit);border-color:var(--crit)}

/* ---------- page / grid ---------- */
.page{max-width:1180px;margin:0 auto;padding:1.8rem 1.6rem 4rem}
.page{max-width:1600px;margin:0 auto;padding:1.8rem 1.6rem 4rem}
.grid{display:grid;grid-template-columns:1.85fr 1fr;gap:1.4rem;align-items:start}

/* ---------- panels ---------- */
Expand Down
2 changes: 2 additions & 0 deletions manager/routers/logging_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ def manager_logs_view(request: Request):
"title": "manager",
"subtitle": f"v: {get_manager_version()}",
"stream_url": "/logs/stream?follow=true&tail=400",
"full_url": "/logs/stream?tail=all",
"export_basename": "manager",
"manager_version": get_manager_version(),
},
)
Expand Down
9 changes: 4 additions & 5 deletions manager/routers/service_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,17 +332,16 @@ def assign_main_service(service: BaseService):
@ROUTER.get("/~logs/view", response_class=HTMLResponse)
def service_logs_view(request: Request, name: str, version: str):
"""HTML view that streams a service's logs with a follow/auto-scroll toggle."""
stream_url = (
f"/services/~logs?name={quote(name)}&version={quote(version)}"
"&follow=true&tail=200"
)
base = f"/services/~logs?name={quote(name)}&version={quote(version)}"
return TEMPLATES.TemplateResponse(
request=request,
name="logs.html",
context={
"title": name,
"subtitle": f"v{version}",
"stream_url": stream_url,
"stream_url": f"{base}&follow=true&tail=200",
"full_url": f"{base}&tail=all",
"export_basename": f"{name}_v{version}",
"manager_version": get_manager_version(),
},
)
Expand Down
115 changes: 102 additions & 13 deletions manager/templates/logs.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/tokens.css">
<style>
body{background:var(--ground);color:var(--text);font-family:var(--sans);margin:0;}
html,body{height:100%;}
body{background:var(--ground);color:var(--text);font-family:var(--sans);margin:0;
display:flex;flex-direction:column;min-height:100vh;}

/* ---------- top bar ---------- */
.top{
Expand All @@ -33,12 +35,13 @@
}
.act:hover{color:var(--text);border-color:var(--accent-dim)}
.act.danger:hover{color:var(--crit);border-color:var(--crit)}
.act:focus-visible{outline:2px solid var(--accent);outline-offset:2px;}

.page{max-width:1180px;margin:0 auto;padding:1.8rem 1.6rem 4rem}
.page{flex:1;min-height:0;display:flex;padding:1.2rem 1.6rem 1.6rem;}

.panel{
background:var(--surface);border:1px solid var(--line-soft);border-radius:16px;
overflow:hidden;
overflow:hidden;flex:1;display:flex;flex-direction:column;min-height:0;width:100%;
}

/* ---------- service status dot ---------- */
Expand All @@ -64,6 +67,14 @@
.live-tag.paused{color:var(--faint)}
.live-tag.paused .d{background:var(--faint);box-shadow:none;animation:none}
.logs-tools{display:flex;align-items:center;gap:.9rem}
.searchbox{
background:var(--ground);border:1px solid var(--line);border-radius:8px;
color:var(--text);font-family:var(--mono);font-size:12px;padding:.35rem .6rem;width:210px;
}
.searchbox::placeholder{color:var(--faint)}
.searchbox:focus{outline:none;border-color:var(--accent-dim);box-shadow:0 0 0 3px rgba(94,230,208,.14)}
.match-count{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;color:var(--faint);min-width:64px}
.console mark{background:rgba(94,230,208,.28);color:var(--text);border-radius:2px;padding:0 1px}

/* follow switch */
.follow{display:inline-flex;align-items:center;gap:.55rem;cursor:pointer;user-select:none}
Expand All @@ -84,9 +95,9 @@
}
.follow input:checked ~ .flabel{color:var(--text)}

.console-wrap{position:relative}
.console-wrap{position:relative;flex:1;min-height:0;display:flex;}
.console{
height:60vh;min-height:320px;overflow-y:auto;overflow-x:hidden;
flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;
font-family:var(--mono);font-size:12.5px;line-height:1.7;
padding:.6rem 0;background:
linear-gradient(180deg,rgba(94,230,208,.025),transparent 120px);
Expand Down Expand Up @@ -144,6 +155,9 @@
<div class="ctx"><span class="sdot run live"></span>
<span class="name">{{ title }}</span><span class="ver">{{ subtitle }}</span></div>
<div class="logs-tools">
<input type="search" id="searchBox" class="searchbox" placeholder="Search logs…" autocomplete="off" aria-label="Search logs">
<span class="match-count" id="matchCount"></span>
<button class="act" id="exportBtn" type="button">Export</button>
<span class="live-tag" id="liveTag"><span class="d"></span><span id="liveLabel">Live</span></span>
<label class="follow" title="Auto-scroll to newest logs">
<input type="checkbox" id="followBox" checked>
Expand All @@ -159,34 +173,54 @@
</div>
<script>
var STREAM_URL = "{{ stream_url|safe }}";
var FULL_URL = "{{ full_url|safe }}";
var EXPORT_BASENAME = "{{ export_basename }}";

var consoleEl=document.getElementById('console'),
followBox=document.getElementById('followBox'),
jumpBtn=document.getElementById('jumpBtn'),
liveTag=document.getElementById('liveTag');
liveTag=document.getElementById('liveTag'),
searchBox=document.getElementById('searchBox'),
matchCount=document.getElementById('matchCount'),
exportBtn=document.getElementById('exportBtn');

var searchActive=false, streamCtrl=null, searchTimer=null, searchCtrl=null;

function nearBottom(){return consoleEl.scrollHeight-consoleEl.scrollTop-consoleEl.clientHeight<24;}
function setLive(on){liveTag.classList.toggle('paused',!on);document.getElementById('liveLabel').textContent=on?'Live':'Paused';}
function classify(line){var u=line.toUpperCase();
if(u.indexOf('ERROR')>-1||u.indexOf('CRITICAL')>-1)return'err';
if(u.indexOf('WARN')>-1)return'warn';return'info';}
function appendLine(text){
if(!text)return;
function newRow(text){
var lvl=classify(text), row=document.createElement('div');
row.className='logline'+(lvl==='err'?' err':lvl==='warn'?' warn':'');
return row;
}
function appendLine(text){
if(!text)return;
var row=newRow(text);
row.textContent=text; // textContent = safe, no XSS from log content
consoleEl.appendChild(row);
while(consoleEl.childElementCount>400)consoleEl.removeChild(consoleEl.firstChild);
if(followBox.checked)consoleEl.scrollTop=consoleEl.scrollHeight; else jumpBtn.classList.add('show');
}
function jumpToLatest(){followBox.checked=true;setLive(true);consoleEl.scrollTop=consoleEl.scrollHeight;jumpBtn.classList.remove('show');}

followBox.addEventListener('change',function(){setLive(followBox.checked);
if(followBox.checked){consoleEl.scrollTop=consoleEl.scrollHeight;jumpBtn.classList.remove('show');}});
consoleEl.addEventListener('scroll',function(){
if(searchActive)return;
if(followBox.checked&&!nearBottom()){followBox.checked=false;setLive(false);jumpBtn.classList.add('show');}
else if(!followBox.checked&&nearBottom()){jumpBtn.classList.remove('show');}});
// stream the real endpoint
(async function(){

// ---- live tail (cancellable) ----
async function startStream(){
if(streamCtrl)streamCtrl.abort();
streamCtrl=new AbortController();
consoleEl.textContent='';
followBox.checked=true; setLive(true); jumpBtn.classList.remove('show');
try{
var resp=await fetch(STREAM_URL,{headers:{'Accept':'text/plain'}});
var resp=await fetch(STREAM_URL,{headers:{'Accept':'text/plain'},signal:streamCtrl.signal});
var reader=resp.body.getReader(), dec=new TextDecoder(), buf='';
while(true){
var r=await reader.read(); if(r.done)break;
Expand All @@ -196,8 +230,63 @@
}
if(buf)appendLine(buf);
setLive(false);
}catch(e){appendLine('— log stream ended —');setLive(false);}
})();
}catch(e){ if(e.name!=='AbortError'){appendLine('— log stream ended —');setLive(false);} }
}

// ---- search: filter to matching lines over full history ----
function appendMatch(text,term){
var row=newRow(text), lc=text.toLowerCase(), q=term.toLowerCase(), i=0, idx;
while((idx=lc.indexOf(q,i))>-1){
if(idx>i)row.appendChild(document.createTextNode(text.slice(i,idx)));
var m=document.createElement('mark'); m.textContent=text.slice(idx,idx+term.length); row.appendChild(m);
i=idx+term.length;
}
if(i<text.length)row.appendChild(document.createTextNode(text.slice(i)));
consoleEl.appendChild(row);
}
async function runSearch(term){
searchActive=true;
if(streamCtrl)streamCtrl.abort();
if(searchCtrl)searchCtrl.abort(); searchCtrl=new AbortController();
setLive(false); jumpBtn.classList.remove('show'); matchCount.textContent='…';
try{
var resp=await fetch(FULL_URL,{headers:{'Accept':'text/plain'},signal:searchCtrl.signal});
var text=await resp.text();
if(!searchActive)return;
var q=term.toLowerCase(), matches=0;
consoleEl.textContent='';
text.split('\n').forEach(function(line){
if(line && line.toLowerCase().indexOf(q)>-1){matches++; appendMatch(line,term);}
});
matchCount.textContent=matches+' match'+(matches===1?'':'es');
}catch(e){ if(e.name!=='AbortError')matchCount.textContent='search failed'; }
}
function clearSearch(){ searchActive=false; matchCount.textContent=''; if(searchCtrl)searchCtrl.abort(); startStream(); }

searchBox.addEventListener('input',function(){
clearTimeout(searchTimer);
var term=searchBox.value;
searchTimer=setTimeout(function(){ if(term.trim()==='')clearSearch(); else runSearch(term); },250);
});

// ---- export: download full history as a .log file ----
exportBtn.addEventListener('click',async function(){
var old=exportBtn.textContent; exportBtn.textContent='…'; exportBtn.disabled=true;
try{
var resp=await fetch(FULL_URL,{headers:{'Accept':'text/plain'}});
var text=await resp.text();
var d=new Date(), p=function(n){return String(n).padStart(2,'0');};
var ts=''+d.getUTCFullYear()+p(d.getUTCMonth()+1)+p(d.getUTCDate())+'-'+p(d.getUTCHours())+p(d.getUTCMinutes())+p(d.getUTCSeconds());
var a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([text],{type:'text/plain'}));
a.download=EXPORT_BASENAME+'_'+ts+'.log';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function(){URL.revokeObjectURL(a.href);},1000);
}catch(e){ alert('Export failed: '+e); }
finally{ exportBtn.textContent=old; exportBtn.disabled=false; }
});

startStream();
</script>
</body>
</html>
40 changes: 40 additions & 0 deletions tests/manager_test/test_ui_redesign.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,43 @@ def test_manager_logs_view_route(test_client):
assert 'id="console"' in body and 'id="followBox"' in body
assert "/logs/stream?follow=true" in body
assert "/assets/tokens.css" in body


def test_dashboard_page_width_widened():
css = (ASSETS / "dashboard_styles.css").read_text().replace(" ", "")
assert "max-width:1600px" in css
assert "max-width:1180px" not in css


def test_logs_view_is_full_bleed():
html = TPL.joinpath("logs.html").read_text().replace(" ", "")
assert "max-width:1180px" not in html # logs page no longer capped
assert "height:60vh" not in html # console no longer fixed-height
assert "min-height:100vh" in html # body fills viewport
assert "flex:1" in html # console/panel flex-fill


def test_service_logs_view_full_url_and_basename(test_client_logged_in):
r = test_client_logged_in.get("/services/~logs/view?name=demo&version=0.1.0")
assert r.status_code == 200
assert "/services/~logs?name=demo&version=0.1.0&tail=all" in r.text
assert 'EXPORT_BASENAME = "demo_v0.1.0"' in r.text


def test_manager_logs_view_full_url_and_basename(test_client):
r = test_client.get("/logs/view")
assert r.status_code == 200
assert "/logs/stream?tail=all" in r.text
assert 'EXPORT_BASENAME = "manager"' in r.text


def test_logs_toolbar_has_search_and_export():
html = TPL.joinpath("logs.html").read_text()
assert 'id="searchBox"' in html
assert 'id="matchCount"' in html
assert 'id="exportBtn"' in html
assert "function runSearch" in html
assert "function startStream" in html # cancellable live tail
assert "EXPORT_BASENAME" in html and "FULL_URL" in html
assert "a.download" in html # triggers a file download
assert "createTextNode" in html # XSS-safe highlight
Loading