")
+ h := DevHandler(dir)
+ get := func(path string) *httptest.ResponseRecorder {
+ r := httptest.NewRecorder()
+ h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, path, nil))
+ return r
+ }
+ first := get("/__dev/revision").Body.String()
+ page := get("/")
+ if page.Code != 200 || page.Header().Get("Cache-Control") != "no-store" || !strings.Contains(page.Body.String(), first) {
+ t.Fatalf("page must embed its uncached revision: %s", page.Body.String())
+ }
+ write("
")
+ if next := get("/__dev/revision").Body.String(); next == first {
+ t.Fatal("UI edit did not change revision")
+ }
+ if page := get("/index.html").Body.String(); !strings.Contains(page, "after") || !strings.Contains(page, "location.reload()") {
+ t.Fatalf("UI edit was not served with reload script: %s", page)
+ }
+ other := httptest.NewRecorder()
+ DevHandler(dir).ServeHTTP(other, httptest.NewRequest(http.MethodGet, "/__dev/revision", nil))
+ if other.Body.String() == get("/__dev/revision").Body.String() {
+ t.Fatal("new server must have a new revision")
+ }
+ if r := get("/../go.mod"); r.Code != http.StatusNotFound {
+ t.Fatal("dev handler must not serve arbitrary files")
+ }
+ if err := os.Remove(filepath.Join(dir, "index.html")); err != nil {
+ t.Fatal(err)
+ }
+ if r := get("/"); r.Code != http.StatusInternalServerError {
+ t.Fatal("missing UI must return a useful error")
+ }
+}
diff --git a/ui/index.html b/ui/index.html
index c322e9d..9a2bc15 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -3,12 +3,13 @@
+
p10logs
@@ -176,6 +197,7 @@
connecting…
+
@@ -187,6 +209,7 @@
+
@@ -205,6 +228,7 @@
+
Export
@@ -242,7 +266,8 @@
const PC=['var(--p0)','var(--p1)','var(--p2)','var(--p3)','var(--p4)','var(--p5)'];
const hash=s=>{let h=2166136261;for(const c of s)h=Math.imul(h^c.charCodeAt(0),16777619);return h>>>0};
const RANGE_MS={'15m':9e5,'1h':36e5,'6h':216e5,'24h':864e5,'7d':6048e5};
-const ROWH=20;
+let ROWH=22; // log row height in px; follows the text size
+const FS=[11,12,13,14,15,16,18,20];let fontSz=13; // A−/A+ steps (px)
// ---------- state ----------
const MAXBUF=10000; // lines kept in memory while tailing; older ones are re-fetched from the hub on scroll-up
@@ -251,7 +276,11 @@
const S={sel:new Set(),range:'1h',follow:true,wrap:false,json:true,ts:'local',q:'',hl:[],lines:[],view:[],head:0,lastR:{},dropped:0,pending:0,gen:0,
streams:{}, // sid -> {cluster,ns,pod,uid,ctr,color,live,key,labels}
tree:[],es:null,next:0,loading:false,collapsed:new Set(),autoCollapsed:new Set(),
- find:'',kinds:new Set(),ns:'',liveOnly:false,crumbFold:null}; // crumbFold: null = auto (fold when many), else user's choice
+ find:'',kinds:new Set(),ns:'',liveOnly:false,crumbFold:null, // crumbFold: null = auto (fold when many), else user's choice
+ pins:new Set(),pinnedOnly:false}; // pinned namespace names (any cluster), kept in this browser
+// Per-browser preferences. Storage can be missing or throw (private mode, blocked site data).
+const PREF={get(k,d){try{const v=localStorage.getItem('p10logs.'+k);return v===null?d:JSON.parse(v)}catch(e){return d}},
+ set(k,v){try{localStorage.setItem('p10logs.'+k,JSON.stringify(v))}catch(e){}}};
const CRUMB_FOLD_AT=8; // selected streams above this collapse the chip list to one line
let lastStart=-1,lastEnd=-1;
@@ -290,31 +319,42 @@
const KD={Deployment:'deploy',StatefulSet:'sts',DaemonSet:'ds',Job:'job',CronJob:'cron',Pod:'pod','DaemonSet/Job':'ds·job',ReplicaSet:'rs',ReplicationController:'rc'};
function podMatches(c,n,p,wl){
if(S.ns&&n.name!==S.ns)return false;
+ if(S.pinnedOnly&&!S.pins.has(n.name))return false;
if(S.liveOnly&&!p.live)return false;
if(S.kinds.size&&!wl.kind.split('/').some(k=>S.kinds.has(k)))return false;
const f=S.find;if(!f)return true;
return p.name.includes(f)||n.name.includes(f)||c.name.includes(f)||wl.name.toLowerCase().includes(f)||wl.kind.toLowerCase().includes(f)||Object.entries(p.labels||{}).some(([k,v])=>(k+'='+v).toLowerCase().includes(f));
}
function nsOptions(){
- const sel=$('nsfilter'),names=[...new Set(S.tree.flatMap(c=>c.namespaces.map(n=>n.name)))].sort();
+ const sel=$('nsfilter'),names=[...new Set(S.tree.flatMap(c=>c.namespaces.map(n=>n.name)))].filter(n=>!S.pinnedOnly||S.pins.has(n)).sort();
if(S.ns&&!names.includes(S.ns))S.ns='';
- sel.innerHTML=''+names.map(n=>``).join('');
+ sel.innerHTML=``+names.map(n=>``).join('');
}
function allKeys(){const ks=[];for(const c of S.tree)for(const n of c.namespaces){const nk=c.name+'/'+n.name;ks.push(nk);const seen=new Set();for(const p of n.pods){const wl=ownerOf(p),k=wl.kind+'/'+wl.name;if(!seen.has(k)){seen.add(k);ks.push(nk+'/'+k)}}}return ks}
+function togglePin(name){S.pins.has(name)?S.pins.delete(name):S.pins.add(name);PREF.set('pins',[...S.pins].sort());nsOptions();buildTree()}
+function setTheme(t){
+ if(!['system','light','dark'].includes(t))t='system';const r=document.documentElement;
+ if(t==='system')delete r.dataset.theme;else r.dataset.theme=t;
+ PREF.set('theme',t);[...$('theme').children].forEach(b=>b.setAttribute('aria-pressed',b.dataset.th===t));
+}
+function setPinnedOnly(on){S.pinnedOnly=on;PREF.set('pinnedOnly',on);$('pinonly').setAttribute('aria-pressed',on);nsOptions();buildTree()}
+
function buildTree(){
const t=$('tree');const now=Date.now()*1e6;const frag=document.createDocumentFragment();
const tw=open=>`${open?'▾':'▸'}`;
const toggle=key=>{S.collapsed.has(key)?S.collapsed.delete(key):S.collapsed.add(key);buildTree()};
for(const c of S.tree){
const ckey=c.name;let cpods=0;const kids=document.createElement('div');kids.className='kids';
- for(const n of c.namespaces){
+ for(const n of c.namespaces.slice().sort((a,b)=>S.pins.has(b.name)-S.pins.has(a.name))){
const groups=new Map();
for(const p of n.pods){const wl=ownerOf(p);if(!podMatches(c,n,p,wl))continue;const k=wl.kind+'/'+wl.name;if(!groups.has(k))groups.set(k,{wl,pods:[]});groups.get(k).pods.push(p)}
if(!groups.size)continue;
const npods=[...groups.values()].reduce((a,g)=>a+g.pods.length,0);cpods+=npods;
const nk=ckey+'/'+n.name;const nwrap=document.createElement('div');if(S.collapsed.has(nk))nwrap.className='collapsed';
- const nn=document.createElement('div');nn.className='node ns';nn.innerHTML=`${tw(!S.collapsed.has(nk))}${esc(n.name)}${npods}`;nn.onclick=()=>toggle(nk);
+ const pinned=S.pins.has(n.name);
+ const nn=document.createElement('div');nn.className='node ns';nn.innerHTML=`${tw(!S.collapsed.has(nk))}${esc(n.name)}${npods}`;nn.onclick=()=>toggle(nk);
+ nn.querySelector('.pin').onclick=e=>{e.stopPropagation();togglePin(n.name)};
nwrap.appendChild(nn);const nkids=document.createElement('div');nkids.className='kids';nwrap.appendChild(nkids);
for(const [gk,g] of [...groups.entries()].sort((a,b)=>a[1].wl.name.localeCompare(b[1].wl.name))){
const wk=nk+'/'+gk;
@@ -355,6 +395,11 @@
cn.onclick=()=>toggle(ckey);
cwrap.appendChild(cn);cwrap.appendChild(kids);frag.appendChild(cwrap);
}
+ if(S.pinnedOnly&&!S.tree.some(c=>c.namespaces.some(n=>S.pins.has(n.name)))){
+ const h=document.createElement('div');h.className='treehint';
+ h.textContent=S.pins.size?'None of the pinned namespaces has streams right now.':'No pinned namespaces yet. Turn off ★ above, then pin namespaces with ☆ next to their name.';
+ frag.prepend(h);
+ }
t.replaceChildren(frag);
$('selcount').textContent=S.sel.size;$('clearsel').disabled=!S.sel.size;
}
@@ -385,6 +430,20 @@
l._h=h;l._g=S.gen;return h;
}
function bump(){S.gen++;lastStart=-1;lastEnd=-1}
+// Log text size: rows stay a whole number of px so the virtual scroll height is exact.
+// Keeps the line at the top of the view (or the bottom, when following) in place.
+function setFont(px){
+ if(!FS.includes(px))px=13;
+ const v=$('view'),top=v.scrollTop/ROWH,bottom=atBottom();
+ fontSz=px;ROWH=Math.round(px*1.7);
+ v.style.setProperty('--fs',px+'px');v.style.setProperty('--rowh',ROWH+'px');
+ $('fsdown').disabled=px===FS[0];$('fsup').disabled=px===FS[FS.length-1];
+ $('fsdown').title=`Smaller log text (now ${px}px) · key -`;$('fsup').title=`Larger log text (now ${px}px) · key +`;
+ PREF.set('fontSize',px);
+ lastStart=-1;lastEnd=-1;draw(true);
+ if(bottom)scrollBottom();else if(!S.wrap)v.scrollTop=Math.round(top*ROWH);
+}
+const fontStep=d=>setFont(FS[Math.min(FS.length-1,Math.max(0,FS.indexOf(fontSz)+d))]);
function earlierPill(){const b=$('earlier');const mem=S.wrap?S.head:0;b.hidden=!(mem>0&&S.sel.size);if(mem>0)b.textContent=`↑ ${mem.toLocaleString()} earlier lines · scroll up`}
// draw renders only what is visible: fixed-height virtual rows, or (wrap mode, variable
// height) a window of the last WIN rows that grows upward on demand.
@@ -555,6 +614,8 @@
$('collapse').addEventListener('click',()=>{allKeys().forEach(k=>S.collapsed.add(k));buildTree()});
$('liveonly').addEventListener('click',e=>{S.liveOnly=!S.liveOnly;e.currentTarget.setAttribute('aria-pressed',S.liveOnly);buildTree()});
$('nsfilter').addEventListener('change',e=>{S.ns=e.target.value;buildTree()});
+$('pinonly').addEventListener('click',()=>setPinnedOnly(!S.pinnedOnly));
+$('theme').addEventListener('click',e=>{const b=e.target.closest('button');if(b)setTheme(b.dataset.th)});
$('kinds').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;const k=b.dataset.k;
if(!k)S.kinds.clear();else S.kinds.has(k)?S.kinds.delete(k):S.kinds.add(k);
[...$('kinds').children].forEach(x=>x.setAttribute('aria-pressed',x.dataset.k?S.kinds.has(x.dataset.k):!S.kinds.size));buildTree()});
@@ -564,18 +625,23 @@
$('wrap').addEventListener('click',e=>{S.wrap=!S.wrap;e.currentTarget.setAttribute('aria-pressed',S.wrap);$('view').classList.toggle('wrap',S.wrap);S.head=S.wrap?Math.max(0,S.view.length-WIN):0;bump();draw(true);if(S.follow)scrollBottom();syncURL()});
$('json').addEventListener('click',e=>{S.json=!S.json;e.currentTarget.setAttribute('aria-pressed',S.json);bump();draw(true);syncURL()});
$('newpill').addEventListener('click',()=>scrollBottom());
+$('fsdown').addEventListener('click',()=>fontStep(-1));$('fsup').addEventListener('click',()=>fontStep(1));
function toast(msg){const t=document.createElement('div');t.className='toast';t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),2600)}
$('link').addEventListener('click',()=>{syncURL();const u=location.href;
const done=()=>toast('Permalink copied');const fail=()=>toast(u);
if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(u).then(done,fail);else fail()});
$('menu').addEventListener('click',()=>$('side').classList.toggle('open'));
-document.addEventListener('keydown',e=>{if(document.activeElement.tagName==='INPUT'||document.activeElement.tagName==='SELECT')return;if(e.key==='/'){e.preventDefault();$('q').focus()}if(e.key==='End'){e.preventDefault();scrollBottom()}if(e.key==='Escape')$('side').classList.remove('open')});
+document.addEventListener('keydown',e=>{if(document.activeElement.tagName==='INPUT'||document.activeElement.tagName==='SELECT')return;if(e.key==='/'){e.preventDefault();$('q').focus()}if(e.key==='End'){e.preventDefault();scrollBottom()}if(e.key==='Escape')$('side').classList.remove('open');
+ if(!e.ctrlKey&&!e.metaKey&&!e.altKey){if(e.key==='+'||e.key==='=')fontStep(1);if(e.key==='-')fontStep(-1)}});
function showTab(t){const logs=t==='logs';$('tab-logs').setAttribute('aria-selected',logs);$('tab-agents').setAttribute('aria-selected',!logs);$('logs-page').style.display=logs?'contents':'none';$('agents-page').classList.toggle('on',!logs);$('side').style.display=logs?'':'none';document.querySelector('.shell').classList.toggle('full',!logs);if(history.replaceState)history.replaceState(null,'',location.pathname+location.search+(logs?'#logs':'#agents'));if(logs){lastStart=-1;draw(true)}else loadStatus()}
$('tab-logs').addEventListener('click',()=>showTab('logs'));$('tab-agents').addEventListener('click',()=>showTab('agents'));
// ---------- boot ----------
(async function(){
const u=new URLSearchParams(location.search);
+ S.pins=new Set((PREF.get('pins',[])||[]).filter(x=>typeof x==='string'));
+ setFont(PREF.get('fontSize',13));setTheme(PREF.get('theme','system'));
+ S.pinnedOnly=PREF.get('pinnedOnly',false)===true;$('pinonly').setAttribute('aria-pressed',S.pinnedOnly);
for(const id of u.getAll('sid'))S.sel.add(String(id));
if(u.get('q')){S.q=u.get('q');$('q').value=S.q}
if(u.get('range')&&RANGE_MS[u.get('range')]){S.range=u.get('range');[...$('range').children].forEach(x=>x.setAttribute('aria-pressed',x.dataset.r===S.range))}