diff --git a/Makefile b/Makefile index 76d8b0f..cacadc6 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ARCH ?= $(shell uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') VER ?= $(shell awk '/^version:/{print $$2}' $(CHART)/Chart.yaml) LDFLAGS := -s -w -X github.com/p10node/p10logs/internal/agent.Version=$(VER) -X main.Version=$(VER) -.PHONY: help build test lint template template-spoke package push-chart images push-images images-tar e2e e2e-kind e2e-multi kind-up kind-down demo run-hub run-agent +.PHONY: help build test lint template template-spoke package push-chart images push-images images-tar e2e e2e-kind e2e-multi kind-up kind-down demo run-hub run-agent dev help: @grep -E '^[a-z0-9-]+:.*##' $(MAKEFILE_LIST) | awk -F'##' '{printf " %-16s %s\n",$$1,$$2}' @@ -19,6 +19,9 @@ test: ## unit tests e2e: build ## local end-to-end (fake /var/log/pods, real agent + hub) ./hack/e2e-local.sh +dev: ## auto-rebuild hub on Go/config edits; auto-refresh browser on UI edits + go run ./hack/dev + run-hub: build ## run a hub on :8080 with ./data (UI auth: none, token: dev) P10_INGEST_TOKEN=dev ./bin/p10logs-hub --config hack/hub-dev.yaml diff --git a/README.md b/README.md index 533ce5a..2a20c8c 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,7 @@ make e2e-multi # 3-node hub cluster + spoke cluster + demo apps on kind; p make demo # apply hack/demo/ (JSON, crash loops, stack traces, 20 KiB lines, CronJob…) to the current context make images-tar # dist/p10logs-images-.tar for nodes without registry access make run-hub # hub on :8080, UI auth none, ingest token "dev", data in ./data +make dev # watch Go/config edits and restart hub; auto-refresh browser on UI edits make package # dist/p10logs-.tgz; before the first GitHub release install from this file # (helm install p10logs dist/p10logs-1.1.1.tgz) with images from `make images-tar` make run-agent # agent tailing ./hack/fakepods into that hub @@ -511,6 +512,15 @@ To see the UI locally without a cluster, run `make run-hub` and `make run-agent` write CRI-format lines into `hack/fakepods/__//0.log` and open http://localhost:8080. +For development, stop the existing hub with Ctrl+C and use `make dev` instead of +`make run-hub`. Open http://127.0.0.1:8080 (refresh once if the page was already open). +Go source, `go.mod`, `go.sum`, and `hack/hub-dev.yaml` changes rebuild and restart +the hub automatically; a failed build leaves the previous hub running. Edits to +`ui/index.html` are served directly from disk and refresh the browser automatically, +without rebuilding Go. The watcher uses only Go's standard library. Ctrl+C stops +both the watcher and its hub. The agent still runs separately. Normal builds keep +using the embedded UI and do not enable browser auto-refresh. + ## FAQ **Why not just use VictoriaLogs?** You can, and it is the lightest persistent backend diff --git a/cmd/p10logs-hub/main.go b/cmd/p10logs-hub/main.go index 10ca3d6..5f2cadb 100644 --- a/cmd/p10logs-hub/main.go +++ b/cmd/p10logs-hub/main.go @@ -29,6 +29,7 @@ var Version = "dev" func main() { cfgPath := flag.String("config", "", "path to hub.yaml") + devUI := flag.String("dev-ui", "", "development only: serve index.html from this directory and auto-refresh browsers") rebuild := flag.Bool("rebuild-index", false, "recreate index.sqlite from chunk files (and object storage) and exit") check := flag.Bool("check", false, "verify chunks, WAL and index; print a JSON report and exit (non-zero if problems)") flag.Parse() @@ -132,9 +133,13 @@ func main() { log.Error("auth", "err", err) os.Exit(1) } + var uiHandler http.Handler + if *devUI != "" { + uiHandler = ui.DevHandler(*devUI) + } srv := api.New(api.Config{MaxBatchBytes: cfg.Limits.MaxBatchBytes, PerClusterBPS: cfg.Limits.PerCluster.BytesPerSecond, MaxConcurrent: cfg.Query.MaxConcurrent, TailMaxClients: cfg.Query.Tail.MaxClients, TailLinesPerSec: cfg.Query.Tail.MaxLinesPerSecondPerClient, TailBuffer: cfg.Query.Tail.BufferLines, - MetricsEnabled: cfg.Metrics.Enabled, Version: Version, Peers: peers}, st, a, ui.Index) + MetricsEnabled: cfg.Metrics.Enabled, Version: Version, Peers: peers, UIHandler: uiHandler}, st, a, ui.Index) hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second} go st.Run(ctx) go func() { diff --git a/hack/dev/main.go b/hack/dev/main.go new file mode 100644 index 0000000..64a43db --- /dev/null +++ b/hack/dev/main.go @@ -0,0 +1,114 @@ +// Development supervisor: no external watcher installation required. +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "io/fs" + "log" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +func sources() (string, error) { + h := sha256.New() + for _, root := range []string{"cmd/p10logs-hub", "internal", "ui", "go.mod", "go.sum", "hack/hub-dev.yaml"} { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || strings.HasSuffix(path, "_test.go") || strings.HasSuffix(path, ".html") { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + fmt.Fprintf(h, "%s\x00%x\n", path, sha256.Sum256(b)) + return nil + }) + if err != nil { + return "", err + } + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + var child *exec.Cmd + var done chan error + stop := func() { + if child == nil { + return + } + _ = child.Process.Signal(os.Interrupt) + select { + case <-done: + case <-time.After(12 * time.Second): + _ = child.Process.Kill() + <-done + } + child, done = nil, nil + } + defer stop() + build := func() { + log.Print("dev: building hub...") + cmd := exec.CommandContext(ctx, "go", "build", "-o", "bin/p10logs-hub-dev", "./cmd/p10logs-hub") + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + log.Print("dev: build failed; keeping the previous hub, waiting for edits") + return + } + stop() + if ctx.Err() != nil { + return + } + child = exec.Command("./bin/p10logs-hub-dev", "--config", "hack/hub-dev.yaml", "--dev-ui", "ui") + child.Env = append(os.Environ(), "P10_INGEST_TOKEN=dev") + child.Stdout, child.Stderr = os.Stdout, os.Stderr + if err := child.Start(); err != nil { + log.Printf("dev: start failed: %v", err) + child = nil + return + } + done = make(chan error, 1) + go func(cmd *exec.Cmd, ch chan error) { ch <- cmd.Wait() }(child, done) + log.Print("dev: http://127.0.0.1:8080 — Go/config: rebuild; UI: browser auto-refresh") + } + previous, err := sources() + if err != nil { + log.Fatal(err) + } + build() + tick := time.NewTicker(500 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case err := <-done: + log.Printf("dev: hub exited (%v); check port 8080 and config, then rerun make dev", err) + child, done = nil, nil + return + case <-tick.C: + next, err := sources() + if err != nil { + log.Printf("dev: watching: %v", err) + continue + } + if next != previous { + previous = next + build() + } + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index d5500b9..13f36a2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -37,6 +37,7 @@ type Config struct { MetricsEnabled bool Version string Peers *federation.Peers + UIHandler http.Handler // optional development UI, still protected by UI auth } // Server holds handlers. @@ -78,7 +79,7 @@ func (s *Server) Handler() http.Handler { ui.HandleFunc("/api/v1/tail", s.tail) ui.HandleFunc("/api/v1/export", s.export) ui.HandleFunc("/api/v1/status", s.status) - ui.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + page := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" && r.URL.Path != "/index.html" { http.NotFound(w, r) return @@ -87,6 +88,11 @@ func (s *Server) Handler() http.Handler { w.Header().Set("Cache-Control", "no-cache") w.Write(s.ui) }) + if s.cfg.UIHandler != nil { + ui.Handle("/", s.cfg.UIHandler) + } else { + ui.Handle("/", page) + } if s.cfg.MetricsEnabled { mux.HandleFunc("/metrics", s.metrics) } diff --git a/ui/dev.go b/ui/dev.go new file mode 100644 index 0000000..1bc2623 --- /dev/null +++ b/ui/dev.go @@ -0,0 +1,58 @@ +package ui + +import ( + "bytes" + "crypto/sha256" + "fmt" + "net/http" + "os" + "path/filepath" + "time" +) + +// DevHandler reads the console on every request. Its revision changes when the +// HTML changes or the hub restarts. Only the hub's explicit --dev-ui flag uses it. +func DevHandler(dir string) http.Handler { + boot := time.Now().UnixNano() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/index.html" && r.URL.Path != "/__dev/revision" { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "no-store") + page, err := os.ReadFile(filepath.Join(dir, "index.html")) + if err != nil { + http.Error(w, "Cannot read development UI index.html", http.StatusInternalServerError) + return + } + revision := fmt.Sprintf("%d-%x", boot, sha256.Sum256(page)) + if r.URL.Path == "/__dev/revision" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprint(w, revision) + return + } + script := fmt.Sprintf(``, revision) + if bytes.Contains(page, []byte("")) { + page = bytes.Replace(page, []byte(""), []byte(script+""), 1) + } else { + page = append(page, []byte(script)...) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(page) + }) +} diff --git a/ui/dev_test.go b/ui/dev_test.go new file mode 100644 index 0000000..0653cc5 --- /dev/null +++ b/ui/dev_test.go @@ -0,0 +1,53 @@ +package ui + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDevHandlerReload(t *testing.T) { + dir := t.TempDir() + write := func(body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + write("before") + 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("after") + 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))}