feat: Preact + TypeScript diagnostics dashboard#526
Conversation
Replace the hand-rolled Alpine.js dashboard with a proper frontend
project using Preact, TypeScript, and Vite. Builds to a single
inlined HTML file via vite-plugin-singlefile.
Project structure (dashboard/):
src/
app.tsx — top-level layout, polling, state
api.ts — typed fetch wrappers
types.ts — TypeScript interfaces for all API responses
style.css — global CSS with WCAG AA contrast ratios
components/
StatusBar.tsx — health pill + version/uptime
SystemRow.tsx — compact system info
MetricBadges.tsx— jemalloc heap, batches, backpressure
ChartGrid.tsx — 6 metric charts (3x2 grid)
Chart.tsx — Canvas time-series with Y-axis, time markers
PipelineView.tsx— flow diagram with click-to-expand inspector
ConfigView.tsx — YAML syntax highlighting
lib/
format.ts — fmt, fmtBytes, fmtDuration, etc
ring.ts — ring buffer for time-series
rates.ts — EMA-smoothed rate tracker
Build: cd dashboard && npm install && npm run build
→ outputs crates/logfwd-io/src/dashboard.html (33KB single file)
Dev: npm run dev (Vite dev server proxies /api/* to localhost:9090)
Based off origin/master (workspace crate structure), not the old
v2-arrow-pipeline branch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Build lifecycle: - The built dashboard.html is committed so cargo build works without Node.js (pure Rust devs never need npm) - `just dashboard` rebuilds from source when editing the frontend - `just dashboard-check` verifies the committed file matches source CI (ci.yml): - New "Dashboard freshness" job: runs `just dashboard-check` to catch stale dashboard.html in PRs Release (release.yml): - Added `npm ci && npm run build` step before cargo build in each matrix job, ensuring release binaries always embed the latest dashboard Justfile: - `just dashboard` — build dashboard (npm install + npm run build) - `just dashboard-check` — verify committed HTML matches source Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughA new Preact-based dashboard frontend is introduced in Possibly related PRs
Comment |
There was a problem hiding this comment.
Requesting changes: dashboard/src/app.tsx has a logic bug in the version expression at line 109.
const version = pipes?.system?.version ?? stats?.uptime_sec != null ? "?" : "";
Due to operator precedence, this evaluates as a ternary over the coalesced value, so a present version string becomes truthy and still resolves to "?" instead of the actual version. Please parenthesize/split the expression so version is the actual string when present.
What is this? | From workflow: AI: PR Review
Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.
There was a problem hiding this comment.
Actionable comments posted: 24
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.github/workflows/release.yml:
- Around line 46-48: Create a new dedicated GitHub Actions job named
dashboard-build that runs the dashboard steps (cd dashboard && npm ci && npm run
build), saves the produced file crates/logfwd-io/src/dashboard.html as an
uploaded artifact, and mark it to run before the matrix build job(s); then in
the existing matrix job(s) replace the repeated build step with an artifact
download step (actions/download-artifact) to retrieve
crates/logfwd-io/src/dashboard.html so each matrix target reuses the single
built dashboard artifact instead of rebuilding it.
In `@crates/logfwd-io/src/dashboard.html`:
- Line 8: The pipeline cards are rendered as clickable divs in the Fe component
(the nodes with class names like "pn inp", "pn xfm", "pn out") which breaks
keyboard accessibility; update the source component so those elements are real
interactive elements (replace the clickable divs used in Fe for inputs,
transform and outputs with <button type="button"> preserving the same class
names, onClick handlers and children) or, if you cannot change the tag, add full
button semantics (role="button", tabIndex=0, keyDown handlers for Enter/Space,
and focus-visible styling) to the same symbols (Fe, the pn elements) and then
rebuild/regenerate the bundled dashboard.html artifact.
In `@dashboard/index.html`:
- Line 5: The diagnostics server currently only serves "/", "/health", "/ready",
and "/api/*" but index.html links to "/favicon.svg", causing a 404; fix by
either adding a GET route for "/favicon.svg" in the diagnostics route setup (so
the diagnostics server returns the favicon asset) or change the HTML to
reference an asset the server already serves (e.g., remove the favicon link or
point it to an existing served path). Locate the diagnostics route registration
code that defines the "/", "/health", "/ready", and "/api/*" handlers and add a
handler for "/favicon.svg" that returns the favicon content (or update
index.html to remove/replace the /favicon.svg href).
In `@dashboard/package.json`:
- Line 8: The package.json "build" npm script uses the Unix-only cp command
which breaks on Windows; update the build script (the "build" entry) to use a
cross-platform copier (e.g., install a devDependency like shx or cpy-cli and
change the command to use shx cp or cpy-cli), or replace the copy step with a
small Node script invoked from the same "build" script; ensure you add the
chosen tool to devDependencies and update the "build" value to use that tool
instead of plain cp.
In `@dashboard/src/api.ts`:
- Around line 3-10: The get<T> function can hang during polling; modify get<T>
to use an AbortController with a short timeout (e.g., configurable default like
5s) so stalled fetches are aborted: create an AbortController, start a
setTimeout that calls controller.abort() after the timeout, pass
controller.signal into fetch(url, { signal }), clear the timeout when fetch
completes successfully, and in the catch return null for both network errors and
AbortError; update only the get<T> function to implement this behavior.
In `@dashboard/src/app.tsx`:
- Around line 28-36: The fds metric ("fds" in createSeries()) is never populated
(poll() never writes to series[5]) and /api/stats doesn't expose an fd field in
diagnostics.rs, so remove or backfill it: either delete the "fds" entry from
createSeries() (and any UI references to the "File Descriptors" card) or
implement end-to-end support by adding an fd field to the stats response in
crates/logfwd-io/src/diagnostics.rs and update poll() to read that value and
write it into series with id "fds". Ensure the chosen fix keeps createSeries(),
poll(), and diagnostics.rs consistent so the card no longer stays in “waiting…”.
- Line 133: ConfigView is rendering despite the backend not exposing
/api/config; either add the missing server endpoint or gate the component
client-side. Fix options: (A) Server-side — add a route handler in the
diagnostics route table (crates/logfwd-io/src/diagnostics.rs) to respond to GET
/api/config with the expected config payload so ConfigView can fetch it; or (B)
Client-side — replace the unconditional <ConfigView /> render in app.tsx with a
guarded render that checks API availability (e.g., call GET /api/config in a
useEffect or a feature-check hook, detect 200 vs 404, and only render ConfigView
when the endpoint exists, otherwise show a placeholder or omit the component).
Ensure the change references ConfigView and the /api/config endpoint so the
component only mounts when the backend supports it.
- Line 26: RateTracker is being created/used inside the render path (compRate /
PipelineView) and compRate calls rates.rate() during render, which mutates
baselines; move instantiation and sampling into the poll() routine so render
only receives plain numeric snapshots. Specifically: stop constructing
RateTracker in app render (remove new RateTracker() from where compRate is
used), create/hold a single RateTracker instance as a module-level or component
instance used only by poll(), call rates.rate() inside poll() to compute
per-pipeline numeric rate snapshots, store those numbers in state (e.g.,
pipelineRateMap), and update PipelineView/compRate to accept plain numbers
(replace the rates prop with rate values) so no mutation occurs during render.
Ensure any code paths still passing the RateTracker instance (the prop mentioned
around PipelineView) are updated to pass the computed numeric snapshot instead.
- Line 109: The expression assigned to the variable version uses ?? and ?:
without grouping, so the ternary binds before the nullish coalescing and yields
incorrect results; fix by keeping pipes?.system?.version as the left side of the
nullish coalescing and wrapping the entire fallback ternary (stats?.uptime_sec
!= null ? "?" : "") in parentheses so the code reads: pipes?.system?.version ??
(stats?.uptime_sec != null ? "?" : ""); update the assignment to version
accordingly.
- Around line 47-48: The polling is currently started with setInterval which
allows overlapping calls to poll() (the function defined as const poll =
useCallback(...)) and causes rates.tick() and state updates to race; replace the
setInterval-based start with a self-scheduling loop that awaits poll() to finish
before waiting POLL_MS and scheduling the next iteration, and ensure the loop
can be stopped on unmount (use a mounted/aborted ref or AbortController inside
the effect that starts the loop). Specifically, locate the effect that starts
the interval (the code that references POLL_MS and calls setInterval to invoke
poll) and change it to an async loop: while (mounted) { await poll(); await
sleep(POLL_MS); } (or equivalent) so only one poll runs at a time and
rates.tick() cannot be called out of sequence.
In `@dashboard/src/components/Chart.tsx`:
- Around line 14-16: The useEffect call currently has no dependency array
causing it to run after every render (intentional here); add an inline comment
above the useEffect explaining that this "render-every-frame" behavior is
deliberate because the parent forces re-renders when data updates, referencing
the draw(canvasRef.current, series) invocation and the canvasRef/series
variables so future readers know this is not an accidental omission of
dependencies.
- Around line 21-23: The draw function uses a non-null assertion on
canvas.getContext("2d") which can be null; update the draw(canvas:
HTMLCanvasElement | null, series: MetricSeries) implementation to safely handle
a null context by assigning const ctx = canvas.getContext("2d") and checking if
(!ctx) return (or log/throw) before using it, removing the "!" and guarding all
subsequent canvas drawing logic to avoid runtime errors.
In `@dashboard/src/components/ChartGrid.tsx`:
- Line 1: ChartGrid is importing MetricSeries from app.tsx which couples the
component to the app entry; extract the MetricSeries type into a new shared
types module (e.g., create a types file exporting MetricSeries) and update
ChartGrid to import MetricSeries from that module instead of app.tsx; also
update app.tsx (and any other files) to import MetricSeries from the new shared
types module and remove the type export from app.tsx so the component is
decoupled.
In `@dashboard/src/components/ConfigView.tsx`:
- Around line 5-18: Add a short, security-focused comment inside the
highlightYaml function explaining why we set el.textContent = yaml and then use
el.innerHTML for regex-based span injection (i.e., textContent safely
HTML-escapes user input before we deliberately inject span tags), note the
importance of doing XSS-safe escaping before replacements and why comments are
processed first; reference the function name highlightYaml and the textContent →
innerHTML pattern so future maintainers understand this is intentional and safe.
- Around line 24-31: The useEffect uses a redundant loaded flag to run once;
replace it with a mount-only effect by changing the dependency array to [] and
removing the loaded state management. In the effect that calls api.config()
(referencing useEffect, setLoaded, loaded, setConfig, and api.config), delete
setLoaded and the loaded checks/variable, and simply call api.config().then(data
=> { if (data) setConfig(data); }) inside a useEffect(() => { ... }, []). Also
remove the related loaded state declaration and any setLoaded usages.
In `@dashboard/src/components/MetricBadges.tsx`:
- Around line 19-24: The truthy checks on stats.mem_allocated and
stats.mem_resident suppress valid zero values; update the condition in
MetricBadges.tsx to use null/undefined-safe checks (e.g., stats.mem_allocated !=
null) before pushing into badges and use stats.mem_resident != null when
deciding the limit so that 0 is treated as a valid value; keep the badge
creation logic (label "Heap", value fmtBytes(stats.mem_allocated), limit using
fmtBytes(stats.mem_resident)) the same otherwise.
In `@dashboard/src/components/PipelineView.tsx`:
- Around line 34-48: The map over p.inputs (the JSX fragment inside p.inputs.map
in PipelineView, which renders Arrow and the div with class "pn inp" and uses
sel and toggle) is missing a key on the outer Fragment; update the iteration to
provide a stable key for each item (e.g., use the loop index or preferably
inp.id) by replacing the shorthand Fragment with a keyed element or adding key
to a long-form Fragment, and do the same for the other mapped block around lines
66-83 so every top-level element returned by .map has a unique key.
- Around line 25-28: The render-time call to rates.rate() in PipelineView.tsx
(inside compRate) mutates RateTracker and breaks the poll/tick contract; fix by
adding a read-only accessor on RateTracker (e.g., getRate(key) or lastRate(key))
that returns the last-computed EMA/delta without updating internal state, and
replace render calls to rates.rate(...) in PipelineView.tsx with this new
getter; ensure the actual rates continue to be computed only in the poll loop in
app.tsx (where rate(...) and tick() are invoked) so RateTracker.rate(...)
remains used only during polling and the read-only method is used during render.
In `@dashboard/src/components/StatusBar.tsx`:
- Around line 14-16: The pillText logic currently always uses the plural
"errors"; update the ternary that builds pillText so it special-cases a single
error: when connected and totalErrors > 0 return "1 error" for totalErrors === 1
and otherwise return `${totalErrors} errors`, keeping the existing "healthy" and
"disconnected" branches; look for the pillText constant and adjust its nested
conditional accordingly.
In `@dashboard/src/lib/format.ts`:
- Around line 16-22: The function fmtBytes currently treats 0 as missing by
returning "-" — update fmtBytes to only treat null/undefined as missing (remove
the b === 0 check) so that 0 bytes format normally; i.e., ensure fmtBytes(b:
number | null | undefined) returns "0 B" (or the appropriate unit) for b === 0
by letting the value fall through to the existing numeric formatting logic in
fmtBytes.
- Around line 31-33: The fmtDuration function returns an empty string for
null/undefined which is inconsistent with other formatters; update fmtDuration
to return the missing-value token "-" when s is null or undefined (i.e., replace
the early return of "" with "-") and keep the rest of the logic (the branch for
>= 86400 and other formatting) unchanged so the function name fmtDuration and
its signature remain the same.
In `@dashboard/src/style.css`:
- Line 19: The CSS custom property --sans currently lists unquoted font family
names (e.g., BlinkMacSystemFont, Roboto) which fails stylelint; update the
--sans declaration in the stylesheet to wrap each multi-word or non-keyword
family name in quotes (e.g., "BlinkMacSystemFont", "Roboto") so the font stack
is preserved and linting for value-keyword-case passes.
In `@dashboard/src/types.ts`:
- Around line 64-86: Remove the unused TypeScript interface SystemResponse from
types.ts: delete the entire export interface SystemResponse declaration (the
symbol SystemResponse) since it is not referenced by any modules (api.ts exports
only pipelines(), stats(), and config()) and will be added later when actually
needed; ensure no other files import or reference SystemResponse before removing
and run type-check to confirm no breakages.
In `@justfile`:
- Around line 49-50: The dashboard recipe in the justfile uses `npm install
--prefer-offline`; update the `dashboard` target to use lockfile-strict installs
by replacing `npm install --prefer-offline` with `npm ci` while keeping the `cd
dashboard &&` and `npm run build` steps intact so the `dashboard` recipe
performs deterministic installs in CI.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: b5e85413-88bc-4409-aacd-10715302a929
⛔ Files ignored due to path filters (3)
dashboard/package-lock.jsonis excluded by!**/package-lock.jsondashboard/public/favicon.svgis excluded by!**/*.svgdashboard/public/icons.svgis excluded by!**/*.svg
📒 Files selected for processing (27)
.github/workflows/ci.yml.github/workflows/release.yml.gitignorecrates/logfwd-io/src/dashboard.htmldashboard/.gitignoredashboard/index.htmldashboard/package.jsondashboard/src/api.tsdashboard/src/app.tsxdashboard/src/components/Chart.tsxdashboard/src/components/ChartGrid.tsxdashboard/src/components/ConfigView.tsxdashboard/src/components/MetricBadges.tsxdashboard/src/components/PipelineView.tsxdashboard/src/components/StatusBar.tsxdashboard/src/components/SystemRow.tsxdashboard/src/lib/format.tsdashboard/src/lib/rates.tsdashboard/src/lib/ring.tsdashboard/src/main.tsxdashboard/src/style.cssdashboard/src/types.tsdashboard/tsconfig.app.jsondashboard/tsconfig.jsondashboard/tsconfig.node.jsondashboard/vite.config.tsjustfile
| - name: Build dashboard | ||
| run: cd dashboard && npm ci && npm run build | ||
|
|
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Build the dashboard once and fan out as an artifact.
On Line 46-48, the same dashboard build is repeated for every matrix target. Consider a dedicated dashboard-build job that produces crates/logfwd-io/src/dashboard.html once, uploads it, and has matrix builds download it. This reduces runtime and external npm dependency risk during releases.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.github/workflows/release.yml around lines 46 - 48, Create a new dedicated
GitHub Actions job named dashboard-build that runs the dashboard steps (cd
dashboard && npm ci && npm run build), saves the produced file
crates/logfwd-io/src/dashboard.html as an uploaded artifact, and mark it to run
before the matrix build job(s); then in the existing matrix job(s) replace the
repeated build step with an artifact download step (actions/download-artifact)
to retrieve crates/logfwd-io/src/dashboard.html so each matrix target reuses the
single built dashboard artifact instead of rebuilding it.
| <link rel="icon" type="image/svg+xml" href="./favicon.svg" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>dashboard</title> | ||
| <script type="module" crossorigin>(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e,t,n,r,i,a,o,s,c,l,u,d={},f=[],p=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,m=Array.isArray;function h(e,t){for(var n in t)e[n]=t[n];return e}function g(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function _(t,n,r){var i,a,o,s={};for(o in n)o==`key`?i=n[o]:o==`ref`?a=n[o]:s[o]=n[o];if(arguments.length>2&&(s.children=arguments.length>3?e.call(arguments,2):r),typeof t==`function`&&t.defaultProps!=null)for(o in t.defaultProps)s[o]===void 0&&(s[o]=t.defaultProps[o]);return v(t,s,i,a,null)}function v(e,r,i,a,o){var s={type:e,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o??++n,__i:-1,__u:0};return o==null&&t.vnode!=null&&t.vnode(s),s}function y(e){return e.children}function b(e,t){this.props=e,this.context=t}function x(e,t){if(t==null)return e.__?x(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])!=null&&n.__e!=null)return n.__e;return typeof e.type==`function`?x(e):null}function S(e){if(e.__P&&e.__d){var n=e.__v,r=n.__e,i=[],a=[],o=h({},n);o.__v=n.__v+1,t.vnode&&t.vnode(o),M(e.__P,o,n,e.__n,e.__P.namespaceURI,32&n.__u?[r]:null,i,r??x(n),!!(32&n.__u),a),o.__v=n.__v,o.__.__k[o.__i]=o,te(i,o,a),n.__e=n.__=null,o.__e!=r&&C(o)}}function C(e){if((e=e.__)!=null&&e.__c!=null)return e.__e=e.__c.base=null,e.__k.some(function(t){if(t!=null&&t.__e!=null)return e.__e=e.__c.base=t.__e}),C(e)}function w(e){(!e.__d&&(e.__d=!0)&&r.push(e)&&!T.__r++||i!=t.debounceRendering)&&((i=t.debounceRendering)||a)(T)}function T(){try{for(var e,t=1;r.length;)r.length>t&&r.sort(o),e=r.shift(),t=r.length,S(e)}finally{r.length=T.__r=0}}function ee(e,t,n,r,i,a,o,s,c,l,u){var p,m,h,g,_,v,y,b=r&&r.__k||f,x=t.length;for(c=E(n,t,b,c,x),p=0;p<x;p++)(h=n.__k[p])!=null&&(m=h.__i!=-1&&b[h.__i]||d,h.__i=p,v=M(e,h,m,i,a,o,s,c,l,u),g=h.__e,h.ref&&m.ref!=h.ref&&(m.ref&&F(m.ref,null,h),u.push(h.ref,h.__c||g,h)),_==null&&g!=null&&(_=g),(y=!!(4&h.__u))||m.__k===h.__k?c=D(h,c,e,y):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-7);return n.__e=_,c}function E(e,t,n,r,i){var a,o,s,c,l,u=n.length,d=u,f=0;for(e.__k=Array(i),a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o==`string`||typeof o==`number`||typeof o==`bigint`||o.constructor==String?o=e.__k[a]=v(null,o,null,null,null):m(o)?o=e.__k[a]=v(y,{children:o},null,null,null):o.constructor===void 0&&o.__b>0?o=e.__k[a]=v(o.type,o.props,o.key,o.ref?o.ref:null,o.__v):e.__k[a]=o,c=a+f,o.__=e,o.__b=e.__b+1,s=null,(l=o.__i=O(o,n,c,d))!=-1&&(d--,(s=n[l])&&(s.__u|=2)),s==null||s.__v==null?(l==-1&&(i>u?f--:i<u&&f++),typeof o.type!=`function`&&(o.__u|=4)):l!=c&&(l==c-1?f--:l==c+1?f++:(l>c?f--:f++,o.__u|=4))):e.__k[a]=null;if(d)for(a=0;a<u;a++)(s=n[a])!=null&&!(2&s.__u)&&(s.__e==r&&(r=x(s)),re(s,s));return r}function D(e,t,n,r){var i,a;if(typeof e.type==`function`){for(i=e.__k,a=0;i&&a<i.length;a++)i[a]&&(i[a].__=e,t=D(i[a],t,n,r));return t}e.__e!=t&&(r&&(t&&e.type&&!t.parentNode&&(t=x(e)),n.insertBefore(e.__e,t||null)),t=e.__e);do t&&=t.nextSibling;while(t!=null&&t.nodeType==8);return t}function O(e,t,n,r){var i,a,o,s=e.key,c=e.type,l=t[n],u=l!=null&&(2&l.__u)==0;if(l===null&&s==null||u&&s==l.key&&c==l.type)return n;if(r>(u?1:0)){for(i=n-1,a=n+1;i>=0||a<t.length;)if((l=t[o=i>=0?i--:a++])!=null&&!(2&l.__u)&&s==l.key&&c==l.type)return o}return-1}function k(e,t,n){t[0]==`-`?e.setProperty(t,n??``):e[t]=n==null?``:typeof n!=`number`||p.test(t)?n:n+`px`}function A(e,t,n,r,i){var a,o;n:if(t==`style`)if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||k(e.style,t,``);if(n)for(t in n)r&&n[t]==r[t]||k(e.style,t,n[t])}else if(t[0]==`o`&&t[1]==`n`)a=t!=(t=t.replace(s,`$1`)),o=t.toLowerCase(),t=o in e||t==`onFocusOut`||t==`onFocusIn`?o.slice(2):t.slice(2),e.l||={},e.l[t+a]=n,n?r?n.u=r.u:(n.u=c,e.addEventListener(t,a?u:l,a)):e.removeEventListener(t,a?u:l,a);else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;break n}catch{}typeof n==`function`||(n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n))}}function j(e){return function(n){if(this.l){var r=this.l[n.type+e];if(n.t==null)n.t=c++;else if(n.t<r.u)return;return r(t.event?t.event(n):n)}}}function M(e,n,r,i,a,o,s,c,l,u){var d,p,_,v,x,S,C,w,T,E,D,O,k,A,j,M=n.type;if(n.constructor!==void 0)return null;128&r.__u&&(l=!!(32&r.__u),o=[c=n.__e=r.__e]),(d=t.__b)&&d(n);n:if(typeof M==`function`)try{if(w=n.props,T=M.prototype&&M.prototype.render,E=(d=M.contextType)&&i[d.__c],D=d?E?E.props.value:d.__:i,r.__c?C=(p=n.__c=r.__c).__=p.__E:(T?n.__c=p=new M(w,D):(n.__c=p=new b(w,D),p.constructor=M,p.render=ie),E&&E.sub(p),p.state||={},p.__n=i,_=p.__d=!0,p.__h=[],p._sb=[]),T&&p.__s==null&&(p.__s=p.state),T&&M.getDerivedStateFromProps!=null&&(p.__s==p.state&&(p.__s=h({},p.__s)),h(p.__s,M.getDerivedStateFromProps(w,p.__s))),v=p.props,x=p.state,p.__v=n,_)T&&M.getDerivedStateFromProps==null&&p.componentWillMount!=null&&p.componentWillMount(),T&&p.componentDidMount!=null&&p.__h.push(p.componentDidMount);else{if(T&&M.getDerivedStateFromProps==null&&w!==v&&p.componentWillReceiveProps!=null&&p.componentWillReceiveProps(w,D),n.__v==r.__v||!p.__e&&p.shouldComponentUpdate!=null&&!1===p.shouldComponentUpdate(w,p.__s,D)){n.__v!=r.__v&&(p.props=w,p.state=p.__s,p.__d=!1),n.__e=r.__e,n.__k=r.__k,n.__k.some(function(e){e&&(e.__=n)}),f.push.apply(p.__h,p._sb),p._sb=[],p.__h.length&&s.push(p);break n}p.componentWillUpdate!=null&&p.componentWillUpdate(w,p.__s,D),T&&p.componentDidUpdate!=null&&p.__h.push(function(){p.componentDidUpdate(v,x,S)})}if(p.context=D,p.props=w,p.__P=e,p.__e=!1,O=t.__r,k=0,T)p.state=p.__s,p.__d=!1,O&&O(n),d=p.render(p.props,p.state,p.context),f.push.apply(p.__h,p._sb),p._sb=[];else do p.__d=!1,O&&O(n),d=p.render(p.props,p.state,p.context),p.state=p.__s;while(p.__d&&++k<25);p.state=p.__s,p.getChildContext!=null&&(i=h(h({},i),p.getChildContext())),T&&!_&&p.getSnapshotBeforeUpdate!=null&&(S=p.getSnapshotBeforeUpdate(v,x)),A=d!=null&&d.type===y&&d.key==null?P(d.props.children):d,c=ee(e,m(A)?A:[A],n,r,i,a,o,s,c,l,u),p.base=n.__e,n.__u&=-161,p.__h.length&&s.push(p),C&&(p.__E=p.__=null)}catch(e){if(n.__v=null,l||o!=null)if(e.then){for(n.__u|=l?160:128;c&&c.nodeType==8&&c.nextSibling;)c=c.nextSibling;o[o.indexOf(c)]=null,n.__e=c}else{for(j=o.length;j--;)g(o[j]);N(n)}else n.__e=r.__e,n.__k=r.__k,e.then||N(n);t.__e(e,n,r)}else o==null&&n.__v==r.__v?(n.__k=r.__k,n.__e=r.__e):c=n.__e=ne(r.__e,n,r,i,a,o,s,l,u);return(d=t.diffed)&&d(n),128&n.__u?void 0:c}function N(e){e&&(e.__c&&(e.__c.__e=!0),e.__k&&e.__k.some(N))}function te(e,n,r){for(var i=0;i<r.length;i++)F(r[i],r[++i],r[++i]);t.__c&&t.__c(n,e),e.some(function(n){try{e=n.__h,n.__h=[],e.some(function(e){e.call(n)})}catch(e){t.__e(e,n.__v)}})}function P(e){return typeof e!=`object`||!e||e.__b>0?e:m(e)?e.map(P):h({},e)}function ne(n,r,i,a,o,s,c,l,u){var f,p,h,_,v,y,b,S=i.props||d,C=r.props,w=r.type;if(w==`svg`?o=`http://www.w3.org/2000/svg`:w==`math`?o=`http://www.w3.org/1998/Math/MathML`:o||=`http://www.w3.org/1999/xhtml`,s!=null){for(f=0;f<s.length;f++)if((v=s[f])&&`setAttribute`in v==!!w&&(w?v.localName==w:v.nodeType==3)){n=v,s[f]=null;break}}if(n==null){if(w==null)return document.createTextNode(C);n=document.createElementNS(o,w,C.is&&C),l&&=(t.__m&&t.__m(r,s),!1),s=null}if(w==null)S===C||l&&n.data==C||(n.data=C);else{if(s&&=e.call(n.childNodes),!l&&s!=null)for(S={},f=0;f<n.attributes.length;f++)S[(v=n.attributes[f]).name]=v.value;for(f in S)v=S[f],f==`dangerouslySetInnerHTML`?h=v:f==`children`||f in C||f==`value`&&`defaultValue`in C||f==`checked`&&`defaultChecked`in C||A(n,f,null,v,o);for(f in C)v=C[f],f==`children`?_=v:f==`dangerouslySetInnerHTML`?p=v:f==`value`?y=v:f==`checked`?b=v:l&&typeof v!=`function`||S[f]===v||A(n,f,v,S[f],o);if(p)l||h&&(p.__html==h.__html||p.__html==n.innerHTML)||(n.innerHTML=p.__html),r.__k=[];else if(h&&(n.innerHTML=``),ee(r.type==`template`?n.content:n,m(_)?_:[_],r,i,a,w==`foreignObject`?`http://www.w3.org/1999/xhtml`:o,s,c,s?s[0]:i.__k&&x(i,0),l,u),s!=null)for(f=s.length;f--;)g(s[f]);l||(f=`value`,w==`progress`&&y==null?n.removeAttribute(`value`):y!=null&&(y!==n[f]||w==`progress`&&!y||w==`option`&&y!=S[f])&&A(n,f,y,S[f],o),f=`checked`,b!=null&&b!=n[f]&&A(n,f,b,S[f],o))}return n}function F(e,n,r){try{if(typeof e==`function`){var i=typeof e.__u==`function`;i&&e.__u(),i&&n==null||(e.__u=e(n))}else e.current=n}catch(e){t.__e(e,r)}}function re(e,n,r){var i,a;if(t.unmount&&t.unmount(e),(i=e.ref)&&(i.current&&i.current!=e.__e||F(i,null,n)),(i=e.__c)!=null){if(i.componentWillUnmount)try{i.componentWillUnmount()}catch(e){t.__e(e,n)}i.base=i.__P=null}if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&re(i[a],n,r||typeof e.type!=`function`);r||g(e.__e),e.__c=e.__=e.__e=void 0}function ie(e,t,n){return this.constructor(e,n)}function ae(n,r,i){var a,o,s,c;r==document&&(r=document.documentElement),t.__&&t.__(n,r),o=(a=typeof i==`function`)?null:i&&i.__k||r.__k,s=[],c=[],M(r,n=(!a&&i||r).__k=_(y,null,[n]),o||d,d,r.namespaceURI,!a&&i?[i]:o?null:r.firstChild?e.call(r.childNodes):null,s,!a&&i?i:o?o.__e:r.firstChild,a,c),te(s,n,c)}e=f.slice,t={__e:function(e,t,n,r){for(var i,a,o;t=t.__;)if((i=t.__c)&&!i.__)try{if((a=i.constructor)&&a.getDerivedStateFromError!=null&&(i.setState(a.getDerivedStateFromError(e)),o=i.__d),i.componentDidCatch!=null&&(i.componentDidCatch(e,r||{}),o=i.__d),o)return i.__E=i}catch(t){e=t}throw e}},n=0,b.prototype.setState=function(e,t){var n=this.__s!=null&&this.__s!=this.state?this.__s:this.__s=h({},this.state);typeof e==`function`&&(e=e(h({},n),this.props)),e&&h(n,e),e!=null&&this.__v&&(t&&this._sb.push(t),w(this))},b.prototype.forceUpdate=function(e){this.__v&&(this.__e=!0,e&&this.__h.push(e),w(this))},b.prototype.render=y,r=[],a=typeof Promise==`function`?Promise.prototype.then.bind(Promise.resolve()):setTimeout,o=function(e,t){return e.__v.__b-t.__v.__b},T.__r=0,s=/(PointerCapture)$|Capture$/i,c=0,l=j(!1),u=j(!0);var I,L,R,oe,z=0,se=[],B=t,ce=B.__b,le=B.__r,ue=B.diffed,de=B.__c,fe=B.unmount,pe=B.__;function V(e,t){B.__h&&B.__h(L,e,z||t),z=0;var n=L.__H||={__:[],__h:[]};return e>=n.__.length&&n.__.push({}),n.__[e]}function H(e){return z=1,me(Se,e)}function me(e,t,n){var r=V(I++,2);if(r.t=e,!r.__c&&(r.__=[n?n(t):Se(void 0,t),function(e){var t=r.__N?r.__N[0]:r.__[0],n=r.t(t,e);t!==n&&(r.__N=[n,r.__[1]],r.__c.setState({}))}],r.__c=L,!L.__f)){var i=function(e,t,n){if(!r.__c.__H)return!0;var i=r.__c.__H.__.filter(function(e){return e.__c});if(i.every(function(e){return!e.__N}))return!a||a.call(this,e,t,n);var o=r.__c.props!==e;return i.some(function(e){if(e.__N){var t=e.__[0];e.__=e.__N,e.__N=void 0,t!==e.__[0]&&(o=!0)}}),a&&a.call(this,e,t,n)||o};L.__f=!0;var a=L.shouldComponentUpdate,o=L.componentWillUpdate;L.componentWillUpdate=function(e,t,n){if(this.__e){var r=a;a=void 0,i(e,t,n),a=r}o&&o.call(this,e,t,n)},L.shouldComponentUpdate=i}return r.__N||r.__}function U(e,t){var n=V(I++,3);!B.__s&&xe(n.__H,t)&&(n.__=e,n.u=t,L.__H.__h.push(n))}function he(e){return z=5,ge(function(){return{current:e}},[])}function ge(e,t){var n=V(I++,7);return xe(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function _e(e,t){return z=8,ge(function(){return e},t)}function ve(){for(var e;e=se.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(W),t.__h.some(G),t.__h=[]}catch(n){t.__h=[],B.__e(n,e.__v)}}}B.__b=function(e){L=null,ce&&ce(e)},B.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),pe&&pe(e,t)},B.__r=function(e){le&&le(e),I=0;var t=(L=e.__c).__H;t&&(R===L?(t.__h=[],L.__h=[],t.__.some(function(e){e.__N&&(e.__=e.__N),e.u=e.__N=void 0})):(t.__h.some(W),t.__h.some(G),t.__h=[],I=0)),R=L},B.diffed=function(e){ue&&ue(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(se.push(t)!==1&&oe===B.requestAnimationFrame||((oe=B.requestAnimationFrame)||be)(ve)),t.__H.__.some(function(e){e.u&&(e.__H=e.u),e.u=void 0})),R=L=null},B.__c=function(e,t){t.some(function(e){try{e.__h.some(W),e.__h=e.__h.filter(function(e){return!e.__||G(e)})}catch(n){t.some(function(e){e.__h&&=[]}),t=[],B.__e(n,e.__v)}}),de&&de(e,t)},B.unmount=function(e){fe&&fe(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.some(function(e){try{W(e)}catch(e){t=e}}),n.__H=void 0,t&&B.__e(t,n.__v))};var ye=typeof requestAnimationFrame==`function`;function be(e){var t,n=function(){clearTimeout(r),ye&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(n,35);ye&&(t=requestAnimationFrame(n))}function W(e){var t=L,n=e.__c;typeof n==`function`&&(e.__c=void 0,n()),L=t}function G(e){var t=L;e.__c=e.__(),L=t}function xe(e,t){return!e||e.length!==t.length||t.some(function(t,n){return t!==e[n]})}function Se(e,t){return typeof t==`function`?t(e):t}async function K(e){try{let t=await fetch(e);return t.ok?await t.json():null}catch{return null}}var q={pipelines:()=>K(`/api/pipelines`),stats:()=>K(`/api/stats`),config:()=>K(`/api/config`)},Ce=class{prev=new Map;ema=new Map;prevTime=null;alpha;constructor(e=.3){this.alpha=e}rate(e,t){let n=Date.now(),r=this.prev.get(e),i=this.prevTime==null?0:(n-this.prevTime)/1e3;if(this.prev.set(e,t),r==null||i<=0)return null;let a=Math.max(0,(t-r)/i),o=this.ema.get(e),s=o==null?a:o*(1-this.alpha)+a*this.alpha;return this.ema.set(e,s),s}tick(){this.prevTime=Date.now()}},J=class{data=[];capacity;constructor(e=150){this.capacity=e}push(e){this.data.push({t:Date.now(),v:e}),this.data.length>this.capacity&&this.data.shift()}points(e=300*1e3){let t=Date.now()-e;return this.data.filter(e=>e.t>=t)}get length(){return this.data.length}};function Y(e){return e==null?`-`:e>=1e9?(e/1e9).toFixed(1)+`G`:e>=1e6?(e/1e6).toFixed(1)+`M`:e>=1e3?(e/1e3).toFixed(1)+`K`:String(Math.round(e))}function we(e){return e>=1e6?(e/1e6).toFixed(1)+`M`:e>=1e3?(e/1e3).toFixed(0)+`K`:e>=1?e.toFixed(0):e<.01?`0`:e.toFixed(1)}function X(e){return e==null||e===0?`-`:e>=1073741824?(e/1073741824).toFixed(1)+` GB`:e>=1048576?(e/1048576).toFixed(1)+` MB`:e>=1024?(e/1024).toFixed(1)+` KB`:e+` B`}function Te(e){return e>=1073741824?(e/1073741824).toFixed(1)+`G`:e>=1048576?(e/1048576).toFixed(0)+`M`:e>=1024?(e/1024).toFixed(0)+`K`:Math.round(e)+`B`}function Ee(e){return e==null?``:e>=86400?Math.floor(e/86400)+`d `+Math.floor(e%86400/3600)+`h`:e>=3600?Math.floor(e/3600)+`h `+Math.floor(e%3600/60)+`m`:e>=60?Math.floor(e/60)+`m `+Math.floor(e%60)+`s`:e+`s`}var De=0;Array.isArray;function Z(e,n,r,i,a,o){n||={};var s,c,l=n;if(`ref`in l)for(c in l={},n)c==`ref`?s=n[c]:l[c]=n[c];var u={type:e,props:l,key:r,ref:s,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--De,__i:-1,__u:0,__source:a,__self:o};if(typeof e==`function`&&(s=e.defaultProps))for(c in s)l[c]===void 0&&(l[c]=s[c]);return t.vnode&&t.vnode(u),u}function Oe({connected:e,totalErrors:t,version:n,uptime:r}){let i=e?t>0?`pill pill-err`:`pill pill-ok`:`pill pill-off`,a=e?t>0?`${t} errors`:`healthy`:`disconnected`;return Z(`div`,{class:`bar`,children:[Z(`h1`,{children:`logfwd`}),Z(`div`,{class:i,children:[Z(`span`,{class:`dot`}),Z(`span`,{children:a})]}),Z(`div`,{class:`spacer`}),Z(`span`,{class:`bar-meta`,children:[`v`,n,` · `,Ee(r)]})]})}function ke({stats:e}){return e?Z(`div`,{class:`section`,children:[Z(`div`,{class:`heading`,children:`System`}),Z(`div`,{class:`sys-row`,children:[e.rss_bytes!=null&&Z(`span`,{children:[`RSS `,Z(`b`,{children:X(e.rss_bytes)})]}),e.mem_allocated!=null&&Z(`span`,{children:[`Heap `,Z(`b`,{children:X(e.mem_allocated)})]}),e.batches>0&&Z(`span`,{children:[`Batches `,Z(`b`,{children:e.batches.toLocaleString()})]})]})]}):null}function Ae({stats:e}){if(!e)return null;let t=[];return e.batches>0&&t.push({label:`Batches`,value:Y(e.batches)}),e.backpressure_stalls>0&&t.push({label:`Backpressure`,value:Y(e.backpressure_stalls)}),e.mem_allocated&&t.push({label:`Heap`,value:X(e.mem_allocated),limit:e.mem_resident?`/ ${X(e.mem_resident)}`:void 0}),t.length===0?null:Z(`div`,{class:`badge-row`,children:t.map(e=>Z(`div`,{class:`mbadge`,children:[Z(`span`,{class:`mbadge-label`,children:e.label}),Z(`span`,{class:`mbadge-val`,children:e.value}),e.limit&&Z(`span`,{class:`mbadge-lim`,children:e.limit})]},e.label))})}var je=300*1e3;function Me({series:e}){let t=he(null);return U(()=>{Ne(t.current,e)}),Z(`canvas`,{ref:t,class:`chart-canvas`})}function Ne(e,t){if(!e||!e.offsetWidth)return;let n=e.getContext(`2d`),r=devicePixelRatio||1,i=e.offsetWidth,a=e.offsetHeight;e.width=i*r,e.height=a*r,n.scale(r,r);let o=i-42-6,s=a-10-16,c=Date.now(),l=t.ring.points(je),u=t.id===`bps`||t.id===`rss`?Te:we;if(n.fillStyle=`#0e1119`,n.fillRect(0,0,i,a),l.length<2){n.fillStyle=`#64748b`,n.font=`10px sans-serif`,n.textAlign=`center`,n.fillText(`waiting…`,i/2,a/2);return}let d=l.map(e=>e.v),f=Math.max(...d)*1.2;f<.001&&(f=1),f<=0&&(f=1);let p=c-l[0].t,m=Math.max(1e4,Math.min(p,je)),h=c-m,g=e=>42+Math.max(0,(e-h)/m*o),_=e=>10+s-(e-0)/(f-0)*s,v=`9px ${getComputedStyle(document.body).getPropertyValue(`--mono`)||`monospace`}`;n.font=v,n.textAlign=`right`,n.textBaseline=`middle`;for(let e=0;e<=3;e++){let t=e/3,r=10+s-s*t;n.strokeStyle=`#1e2536`,n.lineWidth=.5,n.beginPath(),n.moveTo(42,r),n.lineTo(i-6,r),n.stroke(),n.fillStyle=`#94a3b8`,n.fillText(u(0+(f-0)*t),38,r)}n.textAlign=`center`,n.textBaseline=`top`,n.fillStyle=`#94a3b8`,n.font=v;for(let e of[10,30,60,120,300]){let t=g(c-e*1e3);t>60&&t<i-6-30&&n.fillText(e<60?`${e}s`:`${Math.floor(e/60)}m`,t,10+s+3)}n.textAlign=`right`,n.fillText(`now`,i-6,10+s+3),n.beginPath();for(let e=0;e<l.length;e++){let t=g(l[e].t),r=Math.max(10,Math.min(10+s,_(l[e].v)));e===0?n.moveTo(t,r):n.lineTo(t,r)}n.strokeStyle=t.color,n.lineWidth=1.5,n.stroke();let y=g(l[l.length-1].t),b=g(l[0].t);n.lineTo(y,10+s),n.lineTo(b,10+s),n.closePath();let x=n.createLinearGradient(0,10,0,10+s);x.addColorStop(0,t.color+`28`),x.addColorStop(1,t.color+`04`),n.fillStyle=x,n.fill()}function Pe({series:e}){return Z(`div`,{class:`chart-grid`,children:e.map(e=>Z(`div`,{class:`chart-card`,children:[Z(`div`,{class:`chart-head`,children:[Z(`span`,{class:`chart-label`,children:e.label}),Z(`span`,{class:`chart-val`,children:[e.value,Z(`span`,{class:`unit`,children:e.unit}),e.limit&&Z(`span`,{class:`lim`,children:e.limit})]})]}),Z(Me,{series:e})]},e.id))})}var Q=()=>Z(`div`,{class:`pipe-conn`,children:Z(`svg`,{viewBox:`0 0 28 14`,children:[Z(`line`,{x1:`0`,y1:`7`,x2:`20`,y2:`7`}),Z(`polygon`,{points:`20,3 28,7 20,11`})]})});function Fe({pipeline:e,rates:t}){let[n,r]=H(null),i=e=>r(n===e?null:e),a=(e,n)=>{let r=t.rate(`comp_${e.name}_${n}`,e[n]);return r==null?`-`:Y(r)+`/s`};return Z(`div`,{class:`section`,children:[Z(`div`,{class:`heading`,children:[`Pipeline: `,e.name]}),Z(`div`,{class:`pipe-flow`,children:[e.inputs.map((e,t)=>Z(y,{children:[t>0&&Z(Q,{}),Z(`div`,{class:`pn inp ${n===`i${t}`?`selected`:``}`,onClick:()=>i(`i${t}`),children:[Z(`div`,{class:`pn-type`,children:`Input`}),Z(`div`,{class:`pn-name`,children:e.name}),Z(`div`,{class:`pn-row`,children:[Z(`span`,{children:`lines`}),Z(`b`,{children:Y(e.lines_total)})]}),Z(`div`,{class:`pn-row`,children:[Z(`span`,{children:`bytes`}),Z(`b`,{children:X(e.bytes_total)})]}),Z(`div`,{class:`pn-row`,children:[Z(`span`,{children:`rate`}),Z(`b`,{children:a(e,`lines_total`)})]})]})]})),Z(Q,{}),Z(`div`,{class:`pn xfm ${n===`t`?`selected`:``}`,onClick:()=>i(`t`),children:[Z(`div`,{class:`pn-type`,children:`Transform`}),Z(`div`,{class:`pn-name`,children:`SQL`}),Z(`div`,{class:`pn-row`,children:[Z(`span`,{children:`in`}),Z(`b`,{children:Y(e.transform.lines_in)})]}),Z(`div`,{class:`pn-row`,children:[Z(`span`,{children:`out`}),Z(`b`,{children:Y(e.transform.lines_out)})]}),Z(`div`,{class:`pn-row`,children:[Z(`span`,{children:`drop`}),Z(`b`,{style:e.transform.filter_drop_rate>.05?`color:var(--warn)`:``,children:[(e.transform.filter_drop_rate*100).toFixed(1),`%`]})]})]}),Z(Q,{}),e.outputs.map((e,t)=>Z(y,{children:[t>0&&Z(Q,{}),Z(`div`,{class:`pn out ${n===`o${t}`?`selected`:``}`,onClick:()=>i(`o${t}`),children:[Z(`div`,{class:`pn-type`,children:`Output`}),Z(`div`,{class:`pn-name`,children:e.name}),Z(`div`,{class:`pn-row`,children:[Z(`span`,{children:`lines`}),Z(`b`,{children:Y(e.lines_total)})]}),Z(`div`,{class:`pn-row`,children:[Z(`span`,{children:`bytes`}),Z(`b`,{children:X(e.bytes_total)})]}),Z(`div`,{class:`pn-row`,children:[Z(`span`,{children:`errors`}),Z(`b`,{style:e.errors>0?`color:var(--err)`:``,children:e.errors})]})]})]}))]}),n===`t`&&Z(`div`,{class:`inspector`,children:[Z(`div`,{class:`insp-header`,children:[Z(`span`,{class:`insp-title`,children:`Transform — SQL`}),Z(`button`,{class:`insp-close`,onClick:()=>r(null),children:`× close`})]}),Z(`div`,{class:`sql-box`,children:e.transform.sql||`SELECT * FROM logs`}),Z(`div`,{class:`insp-grid`,children:[Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Lines In`}),Z(`div`,{class:`ik-v`,children:Y(e.transform.lines_in)})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Lines Out`}),Z(`div`,{class:`ik-v`,children:Y(e.transform.lines_out)})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Drop Rate`}),Z(`div`,{class:`ik-v`,children:[(e.transform.filter_drop_rate*100).toFixed(1),`%`]})]}),e.scan_sec!=null&&Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Scan Time`}),Z(`div`,{class:`ik-v`,children:[e.scan_sec.toFixed(3),`s`]})]}),e.transform_sec!=null&&Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Transform Time`}),Z(`div`,{class:`ik-v`,children:[e.transform_sec.toFixed(3),`s`]})]})]})]}),n?.startsWith(`i`)&&(()=>{let t=parseInt(n.slice(1)),i=e.inputs[t];return i?Z(`div`,{class:`inspector`,children:[Z(`div`,{class:`insp-header`,children:[Z(`span`,{class:`insp-title`,children:[`Input — `,i.name]}),Z(`button`,{class:`insp-close`,onClick:()=>r(null),children:`× close`})]}),Z(`div`,{class:`insp-grid`,children:[Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Name`}),Z(`div`,{class:`ik-v`,children:i.name})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Type`}),Z(`div`,{class:`ik-v`,children:i.type})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Lines`}),Z(`div`,{class:`ik-v`,children:Y(i.lines_total)})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Bytes`}),Z(`div`,{class:`ik-v`,children:X(i.bytes_total)})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Errors`}),Z(`div`,{class:`ik-v`,children:i.errors})]})]})]}):null})(),n?.startsWith(`o`)&&(()=>{let t=parseInt(n.slice(1)),i=e.outputs[t];return i?Z(`div`,{class:`inspector`,children:[Z(`div`,{class:`insp-header`,children:[Z(`span`,{class:`insp-title`,children:[`Output — `,i.name]}),Z(`button`,{class:`insp-close`,onClick:()=>r(null),children:`× close`})]}),Z(`div`,{class:`insp-grid`,children:[Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Name`}),Z(`div`,{class:`ik-v`,children:i.name})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Type`}),Z(`div`,{class:`ik-v`,children:i.type})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Lines`}),Z(`div`,{class:`ik-v`,children:Y(i.lines_total)})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Bytes`}),Z(`div`,{class:`ik-v`,children:X(i.bytes_total)})]}),Z(`div`,{class:`insp-kv`,children:[Z(`div`,{class:`ik-l`,children:`Errors`}),Z(`div`,{class:`ik-v`,style:i.errors>0?`color:var(--err)`:``,children:i.errors})]})]})]}):null})()]})}function Ie(e){let t=document.createElement(`div`);t.textContent=e;let n=t.innerHTML;return n=n.replace(/(#.*)$/gm,`<span class="yc">$1</span>`),n=n.replace(/^(\s*)([\w._-]+)(:)/gm,`$1<span class="yk">$2</span>$3`),n=n.replace(/:\s+(\d+(?:\.\d+)?)\s*$/gm,`: <span class="yn">$1</span>`),n=n.replace(/:\s+(true|false)\s*$/gm,`: <span class="yn">$1</span>`),n}function Le(){let[e,t]=H(null),[n,r]=H(!1);return U(()=>{n||(r(!0),q.config().then(e=>{e&&t(e)}))},[n]),Z(`div`,{class:`section`,children:[Z(`div`,{class:`heading`,children:`Config`}),e?.path&&Z(`div`,{class:`cfg-src`,children:[`Source: `,Z(`b`,{children:e.path})]}),e?Z(`div`,{class:`yaml`,dangerouslySetInnerHTML:{__html:Ie(e.raw_yaml||`(no config)`)}}):Z(`div`,{class:`yaml`,style:`color:var(--t4)`,children:`loading…`})]})}var Re=2e3,$=new Ce;function ze(){return[{id:`lps`,label:`Lines / sec`,color:`#3b82f6`,ring:new J,value:`-`,unit:`/s`},{id:`bps`,label:`Input Bytes`,color:`#8b5cf6`,ring:new J,value:`-`,unit:`/s`},{id:`err`,label:`Errors / sec`,color:`#ef4444`,ring:new J,value:`-`,unit:`/s`},{id:`cpu`,label:`Process CPU`,color:`#f59e0b`,ring:new J,value:`-`,unit:`%`},{id:`rss`,label:`Memory (RSS)`,color:`#10b981`,ring:new J,value:`-`,unit:``},{id:`fds`,label:`File Descriptors`,color:`#06b6d4`,ring:new J,value:`-`,unit:``}]}function Be(){let[e,t]=H(!1),[n,r]=H(null),[i,a]=H(null),[o,s]=H(0),c=he(ze()),[,l]=H(0),u=_e(async()=>{let[e,n]=await Promise.all([q.pipelines(),q.stats()]);if(e){t(!0),r(e);let n=0,i=0,a=0;for(let t of e.pipelines){n+=t.transform.lines_in;for(let e of t.inputs)i+=e.bytes_total;for(let e of t.outputs)a+=e.errors}s(a);let o=c.current,l=$.rate(`lps`,n),u=$.rate(`bps`,i),d=$.rate(`eps`,a);l!=null&&(o[0].ring.push(l),o[0].value=Y(l)),u!=null&&(o[1].ring.push(u),o[1].value=X(u)),d!=null&&(o[2].ring.push(d),o[2].value=Y(d))}else t(!1);if(n){a(n);let e=c.current;if(n.cpu_user_ms!=null&&n.cpu_sys_ms!=null){let t=n.cpu_user_ms+n.cpu_sys_ms,r=$.rate(`cpu_ms`,t);if(r!=null){let t=r/10;e[3].ring.push(t),e[3].value=t.toFixed(1)}}n.rss_bytes!=null&&(e[4].ring.push(n.rss_bytes),e[4].value=X(n.rss_bytes),n.mem_resident&&(e[4].limit=`/ `+X(n.mem_resident)+` resident`))}$.tick(),l(e=>e+1)},[]);return U(()=>{u();let e=setInterval(u,Re);return()=>clearInterval(e)},[u]),Z(y,{children:[Z(Oe,{connected:e,totalErrors:o,version:n?.system?.version??i?.uptime_sec!=null?`?`:``,uptime:i?.uptime_sec??n?.system?.uptime_seconds??0}),Z(`main`,{children:[Z(ke,{stats:i}),Z(Ae,{stats:i}),Z(`div`,{class:`section`,children:[Z(`div`,{class:`heading`,children:`Metrics`}),Z(Pe,{series:c.current})]}),n?.pipelines.map(e=>Z(Fe,{pipeline:e,rates:$},e.name)),Z(Le,{})]})]})}ae(Z(Be,{}),document.getElementById(`app`));</script> |
There was a problem hiding this comment.
Use real buttons for the pipeline cards.
The generated bundle renders each .pn node as a clickable div with onClick only, so keyboard users cannot open the inspectors. Please fix the source component to use <button type="button"> (preferred) or add button semantics, Enter/Space handling, and :focus-visible styling, then regenerate this artifact.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/logfwd-io/src/dashboard.html` at line 8, The pipeline cards are
rendered as clickable divs in the Fe component (the nodes with class names like
"pn inp", "pn xfm", "pn out") which breaks keyboard accessibility; update the
source component so those elements are real interactive elements (replace the
clickable divs used in Fe for inputs, transform and outputs with <button
type="button"> preserving the same class names, onClick handlers and children)
or, if you cannot change the tag, add full button semantics (role="button",
tabIndex=0, keyDown handlers for Enter/Space, and focus-visible styling) to the
same symbols (Fe, the pn elements) and then rebuild/regenerate the bundled
dashboard.html artifact.
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> |
There was a problem hiding this comment.
/favicon.svg will 404 on the diagnostics server.
On Line 5, the server route set only serves /, /health, /ready, and /api/*; it does not serve /favicon.svg (crates/logfwd-io/src/diagnostics.rs, Line 295-405). This creates a persistent broken asset request.
Suggested fix
- <link rel="icon" type="image/svg+xml" href="https://github.com/favicon.svg" />📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <link rel="icon" type="image/svg+xml" href="https://github.com/favicon.svg" /> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/index.html` at line 5, The diagnostics server currently only serves
"/", "/health", "/ready", and "/api/*" but index.html links to "/favicon.svg",
causing a 404; fix by either adding a GET route for "/favicon.svg" in the
diagnostics route setup (so the diagnostics server returns the favicon asset) or
change the HTML to reference an asset the server already serves (e.g., remove
the favicon link or point it to an existing served path). Locate the diagnostics
route registration code that defines the "/", "/health", "/ready", and "/api/*"
handlers and add a handler for "/favicon.svg" that returns the favicon content
(or update index.html to remove/replace the /favicon.svg href).
| "type": "module", | ||
| "scripts": { | ||
| "dev": "vite", | ||
| "build": "tsc -b && vite build && cp ../crates/logfwd-io/src/dashboard-dist/index.html ../crates/logfwd-io/src/dashboard.html", |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Build script uses Unix-only cp command.
This will fail on Windows. Consider using a cross-platform approach like shx or cpy-cli, or document that the build requires a Unix-like environment.
♻️ Cross-platform alternative using shx
- "build": "tsc -b && vite build && cp ../crates/logfwd-io/src/dashboard-dist/index.html ../crates/logfwd-io/src/dashboard.html",
+ "build": "tsc -b && vite build && shx cp ../crates/logfwd-io/src/dashboard-dist/index.html ../crates/logfwd-io/src/dashboard.html",Add shx to devDependencies:
"vite": "^8.0.1",
- "vite-plugin-singlefile": "^2.3.2"
+ "vite-plugin-singlefile": "^2.3.2",
+ "shx": "^0.3.4"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/package.json` at line 8, The package.json "build" npm script uses
the Unix-only cp command which breaks on Windows; update the build script (the
"build" entry) to use a cross-platform copier (e.g., install a devDependency
like shx or cpy-cli and change the command to use shx cp or cpy-cli), or replace
the copy step with a small Node script invoked from the same "build" script;
ensure you add the chosen tool to devDependencies and update the "build" value
to use that tool instead of plain cp.
| async function get<T>(url: string): Promise<T | null> { | ||
| try { | ||
| const res = await fetch(url); | ||
| if (!res.ok) return null; | ||
| return (await res.json()) as T; | ||
| } catch { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -path "*/dashboard/src/api.ts" -o -path "*/api.ts" | head -20Repository: strawgate/memagent
Length of output: 84
🏁 Script executed:
git ls-files | grep -E "(api|dashboard)" | head -30Repository: strawgate/memagent
Length of output: 941
🏁 Script executed:
fd "api.ts" --type fRepository: strawgate/memagent
Length of output: 82
🏁 Script executed:
cat -n dashboard/src/api.tsRepository: strawgate/memagent
Length of output: 648
🏁 Script executed:
rg "get<" dashboard/src --type ts -A 3 -B 1Repository: strawgate/memagent
Length of output: 589
🏁 Script executed:
rg "polling\|interval\|subscribe\|refresh" dashboard/src --type ts -B 2 -A 2Repository: strawgate/memagent
Length of output: 44
🏁 Script executed:
rg "api\.(pipelines|stats|config)" dashboard/src --type ts -B 2 -A 2Repository: strawgate/memagent
Length of output: 645
🏁 Script executed:
rg "setInterval|useEffect|setImmediate" dashboard/src --type ts -B 1 -A 3Repository: strawgate/memagent
Length of output: 1757
🏁 Script executed:
cat -n dashboard/src/app.tsx | head -100Repository: strawgate/memagent
Length of output: 4506
🏁 Script executed:
cat -n dashboard/src/app.tsx | tail -30Repository: strawgate/memagent
Length of output: 989
🏁 Script executed:
rg "AbortController|timeout|fetch.*signal" dashboard/src --type tsRepository: strawgate/memagent
Length of output: 44
Add a timeout to prevent hanging fetches in recurring poll requests.
The get<T> function is used in polling (2-second intervals) via Promise.all([api.pipelines(), api.stats()]). Without abort timeout, a stalled request blocks poll completion, degrading dashboard freshness.
Proposed change
async function get<T>(url: string): Promise<T | null> {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 5000);
try {
- const res = await fetch(url);
+ const res = await fetch(url, { signal: controller.signal });
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
+ } finally {
+ clearTimeout(timeout);
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function get<T>(url: string): Promise<T | null> { | |
| try { | |
| const res = await fetch(url); | |
| if (!res.ok) return null; | |
| return (await res.json()) as T; | |
| } catch { | |
| return null; | |
| } | |
| async function get<T>(url: string): Promise<T | null> { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 5000); | |
| try { | |
| const res = await fetch(url, { signal: controller.signal }); | |
| if (!res.ok) return null; | |
| return (await res.json()) as T; | |
| } catch { | |
| return null; | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/api.ts` around lines 3 - 10, The get<T> function can hang
during polling; modify get<T> to use an AbortController with a short timeout
(e.g., configurable default like 5s) so stalled fetches are aborted: create an
AbortController, start a setTimeout that calls controller.abort() after the
timeout, pass controller.signal into fetch(url, { signal }), clear the timeout
when fetch completes successfully, and in the catch return null for both network
errors and AbortError; update only the get<T> function to implement this
behavior.
| export function fmtBytes(b: number | null | undefined): string { | ||
| if (b == null || b === 0) return "-"; | ||
| if (b >= 1073741824) return (b / 1073741824).toFixed(1) + " GB"; | ||
| if (b >= 1048576) return (b / 1048576).toFixed(1) + " MB"; | ||
| if (b >= 1024) return (b / 1024).toFixed(1) + " KB"; | ||
| return b + " B"; | ||
| } |
There was a problem hiding this comment.
Do not map 0 bytes to missing data.
On Line 17, returning "-" for 0 makes valid zero values indistinguishable from null/absent metrics.
Suggested fix
export function fmtBytes(b: number | null | undefined): string {
- if (b == null || b === 0) return "-";
+ if (b == null) return "-";
+ if (b === 0) return "0 B";
if (b >= 1073741824) return (b / 1073741824).toFixed(1) + " GB";
if (b >= 1048576) return (b / 1048576).toFixed(1) + " MB";
if (b >= 1024) return (b / 1024).toFixed(1) + " KB";
return b + " B";
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function fmtBytes(b: number | null | undefined): string { | |
| if (b == null || b === 0) return "-"; | |
| if (b >= 1073741824) return (b / 1073741824).toFixed(1) + " GB"; | |
| if (b >= 1048576) return (b / 1048576).toFixed(1) + " MB"; | |
| if (b >= 1024) return (b / 1024).toFixed(1) + " KB"; | |
| return b + " B"; | |
| } | |
| export function fmtBytes(b: number | null | undefined): string { | |
| if (b == null) return "-"; | |
| if (b === 0) return "0 B"; | |
| if (b >= 1073741824) return (b / 1073741824).toFixed(1) + " GB"; | |
| if (b >= 1048576) return (b / 1048576).toFixed(1) + " MB"; | |
| if (b >= 1024) return (b / 1024).toFixed(1) + " KB"; | |
| return b + " B"; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/lib/format.ts` around lines 16 - 22, The function fmtBytes
currently treats 0 as missing by returning "-" — update fmtBytes to only treat
null/undefined as missing (remove the b === 0 check) so that 0 bytes format
normally; i.e., ensure fmtBytes(b: number | null | undefined) returns "0 B" (or
the appropriate unit) for b === 0 by letting the value fall through to the
existing numeric formatting logic in fmtBytes.
| export function fmtDuration(s: number | null | undefined): string { | ||
| if (s == null) return ""; | ||
| if (s >= 86400) return Math.floor(s / 86400) + "d " + Math.floor((s % 86400) / 3600) + "h"; |
There was a problem hiding this comment.
Use a consistent placeholder for missing duration values.
On Line 32, "" for null duration can render as a blank field; use the same missing-value token ("-") as other formatters.
Suggested fix
export function fmtDuration(s: number | null | undefined): string {
- if (s == null) return "";
+ if (s == null) return "-";
if (s >= 86400) return Math.floor(s / 86400) + "d " + Math.floor((s % 86400) / 3600) + "h";
if (s >= 3600) return Math.floor(s / 3600) + "h " + Math.floor((s % 3600) / 60) + "m";
if (s >= 60) return Math.floor(s / 60) + "m " + Math.floor(s % 60) + "s";
return s + "s";
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function fmtDuration(s: number | null | undefined): string { | |
| if (s == null) return ""; | |
| if (s >= 86400) return Math.floor(s / 86400) + "d " + Math.floor((s % 86400) / 3600) + "h"; | |
| export function fmtDuration(s: number | null | undefined): string { | |
| if (s == null) return "-"; | |
| if (s >= 86400) return Math.floor(s / 86400) + "d " + Math.floor((s % 86400) / 3600) + "h"; | |
| if (s >= 3600) return Math.floor(s / 3600) + "h " + Math.floor((s % 3600) / 60) + "m"; | |
| if (s >= 60) return Math.floor(s / 60) + "m " + Math.floor(s % 60) + "s"; | |
| return s + "s"; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/lib/format.ts` around lines 31 - 33, The fmtDuration function
returns an empty string for null/undefined which is inconsistent with other
formatters; update fmtDuration to return the missing-value token "-" when s is
null or undefined (i.e., replace the early return of "" with "-") and keep the
rest of the logic (the branch for >= 86400 and other formatting) unchanged so
the function name fmtDuration and its signature remain the same.
| --t4: #7589a3; /* labels: 4.6:1 (AA minimum) */ | ||
|
|
||
| --mono: "SF Mono", "Cascadia Code", "Fira Code", "Menlo", monospace; | ||
| --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
There was a problem hiding this comment.
Quote the font family names so lint passes.
Stylelint is already failing on BlinkMacSystemFont and Roboto here under value-keyword-case. Quoting them preserves the stack and unblocks CI.
Suggested change
- --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --sans: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| --sans: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; |
🧰 Tools
🪛 Stylelint (17.5.0)
[error] 19-19: Expected "BlinkMacSystemFont" to be "blinkmacsystemfont" (value-keyword-case)
(value-keyword-case)
[error] 19-19: Expected "Roboto" to be "roboto" (value-keyword-case)
(value-keyword-case)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/style.css` at line 19, The CSS custom property --sans currently
lists unquoted font family names (e.g., BlinkMacSystemFont, Roboto) which fails
stylelint; update the --sans declaration in the stylesheet to wrap each
multi-word or non-keyword family name in quotes (e.g., "BlinkMacSystemFont",
"Roboto") so the font stack is preserved and linting for value-keyword-case
passes.
| dashboard: | ||
| cd dashboard && npm install --prefer-offline && npm run build |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Use npm ci for deterministic dashboard builds.
dashboard-check runs this in CI, so lockfile-strict installs are safer and more reproducible than npm install.
Proposed change
dashboard:
- cd dashboard && npm install --prefer-offline && npm run build
+ cd dashboard && npm ci --prefer-offline && npm run build📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| dashboard: | |
| cd dashboard && npm install --prefer-offline && npm run build | |
| dashboard: | |
| cd dashboard && npm ci && npm run build |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@justfile` around lines 49 - 50, The dashboard recipe in the justfile uses
`npm install --prefer-offline`; update the `dashboard` target to use
lockfile-strict installs by replacing `npm install --prefer-offline` with `npm
ci` while keeping the `cd dashboard &&` and `npm run build` steps intact so the
`dashboard` recipe performs deterministic installs in CI.
Rust changes: - Add config_yaml/config_path fields to DiagnosticsServer - New set_config() method, called from main.rs at startup - New /api/config endpoint returns raw YAML + file path - main.rs reads config as string before parsing, passes both to run_pipelines for the diagnostics server Dashboard fixes for master's actual API: - Use mem_allocated (jemalloc) instead of rss_bytes (null on macOS) - Replaced FDs chart with Batch Latency (computed from scan_sec + transform_sec + output_sec / batches) - Removed SystemRow (duplicated MetricBadges) - Config tab now works (loads from /api/config) - Version extracted from /api/pipelines response Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (2)
crates/logfwd-io/src/dashboard.html (1)
8-8:⚠️ Potential issue | 🟠 MajorUse real buttons for the pipeline cards.
The shipped bundle still renders the
.pncards as clickabledivs withonClickonly. Keyboard users cannot open the inspectors. Fixdashboard/src/components/PipelineView.tsxto use<button type="button">(or equivalent button semantics) and regenerate this artifact.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/logfwd-io/src/dashboard.html` at line 8, The pipeline cards are rendered as clickable divs (.pn) with onClick only, blocking keyboard users; update the PipelineView component (the Pe / PipelineView.tsx logic that renders elements with class `pn inp`, `pn xfm`, `pn out`) to use real <button type="button"> elements (preserve the existing class names, children structure and onClick handlers), ensure buttons have type="button" to avoid form submission, and keep any visual styling intact; after changing the elements, rebuild/regenerate the bundled dashboard artifact so the compiled dashboard.html includes the button changes.dashboard/src/app.tsx (1)
114-118:⚠️ Potential issue | 🟠 MajorSerialize the polling loop.
setIntervaldoes not wait for the previous asyncpoll()to finish. Slow responses will overlap and race on the sharedratestracker andseriesRef.currentmutations.Possible fix
- useEffect(() => { - poll(); - const id = setInterval(poll, POLL_MS); - return () => clearInterval(id); - }, [poll]); + useEffect(() => { + let cancelled = false; + let timeoutId: number | undefined; + + const loop = async () => { + await poll(); + if (!cancelled) timeoutId = window.setTimeout(loop, POLL_MS); + }; + + void loop(); + return () => { + cancelled = true; + if (timeoutId != null) window.clearTimeout(timeoutId); + }; + }, [poll]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/app.tsx` around lines 114 - 118, The polling loop currently uses setInterval which can start a new async poll() before the previous one finishes, causing races on rates and seriesRef.current; replace the setInterval approach in the useEffect with a serialized async loop: create an async function (e.g., runSerializedPoll) that while mounted awaits poll(), then waits POLL_MS via setTimeout (or await sleep) before the next iteration, store the timeout id and a mounted flag for cleanup, and on effect cleanup set mounted=false and clear the timeout; ensure you reference the existing poll function and the shared state (rates and seriesRef.current) so all invocations are awaited and never overlap.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/logfwd-io/src/diagnostics.rs`:
- Around line 388-389: The current diagnostics route returns the raw YAML config
via DiagnosticsServer::serve_config which leaks secrets; modify serve_config (or
replace it with serve_redacted_config) to never return the raw YAML by default —
either redact sensitive keys (match names like "password", "token", "secret",
"api_key", file paths) using a redaction helper and return a safe projection, or
require an explicit opt-in/auth flag on the DiagnosticsServer struct (e.g., an
allow_raw_config boolean checked in DiagnosticsServer::serve_config) before
serving full config; update the route mapping that currently points to
serve_config and any other handlers in the same file (around the other
occurrences noted) to use the redaction helper or enforce the opt-in check.
In `@dashboard/src/app.tsx`:
- Around line 99-106: The latency is being computed as total processing ms/sec
instead of ms per batch; replace the single call to rates.rate("lat", totalSec *
1000) with a per-batch computation: get totalMsRate = rates.rate("lat", totalSec
* 1000) and batchesRate = rates.rate("batches", 1) and compute latRate =
(batchesRate && batchesRate > 0) ? totalMsRate / batchesRate :
(statsData.batches > 0 ? (totalSec * 1000) / statsData.batches : null); then
push latRate into series[5].ring and set series[5].value = latRate.toFixed(1)
only when latRate is not null (keep existing symbols statsData.batches,
totalSec, rates.rate("lat", ...), rates.rate("batches", 1), series[5].ring,
series[5].value).
---
Duplicate comments:
In `@crates/logfwd-io/src/dashboard.html`:
- Line 8: The pipeline cards are rendered as clickable divs (.pn) with onClick
only, blocking keyboard users; update the PipelineView component (the Pe /
PipelineView.tsx logic that renders elements with class `pn inp`, `pn xfm`, `pn
out`) to use real <button type="button"> elements (preserve the existing class
names, children structure and onClick handlers), ensure buttons have
type="button" to avoid form submission, and keep any visual styling intact;
after changing the elements, rebuild/regenerate the bundled dashboard artifact
so the compiled dashboard.html includes the button changes.
In `@dashboard/src/app.tsx`:
- Around line 114-118: The polling loop currently uses setInterval which can
start a new async poll() before the previous one finishes, causing races on
rates and seriesRef.current; replace the setInterval approach in the useEffect
with a serialized async loop: create an async function (e.g., runSerializedPoll)
that while mounted awaits poll(), then waits POLL_MS via setTimeout (or await
sleep) before the next iteration, store the timeout id and a mounted flag for
cleanup, and on effect cleanup set mounted=false and clear the timeout; ensure
you reference the existing poll function and the shared state (rates and
seriesRef.current) so all invocations are awaited and never overlap.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 418a78dc-2138-4dc4-a7b5-5e96d366c453
📒 Files selected for processing (4)
crates/logfwd-io/src/dashboard.htmlcrates/logfwd-io/src/diagnostics.rscrates/logfwd/src/main.rsdashboard/src/app.tsx
| // Batch latency: total processing time / batches | ||
| if (statsData.batches > 0) { | ||
| const totalSec = statsData.scan_sec + statsData.transform_sec + statsData.output_sec; | ||
| const latRate = rates.rate("lat", totalSec * 1000); | ||
| if (latRate != null) { | ||
| series[5].ring.push(latRate); | ||
| series[5].value = latRate.toFixed(1); | ||
| } |
There was a problem hiding this comment.
Compute batch latency from batch deltas.
rates.rate("lat", totalSec * 1000) gives accumulated processing milliseconds per second, not milliseconds per batch. At 100 batches/s with 5 ms of work each, this card reports ~500 ms instead of 5 ms. Divide the stage-time delta by the batch-count delta, or divide rate(total_ms) by rate(batches).
Possible fix
- // Batch latency: total processing time / batches
- if (statsData.batches > 0) {
- const totalSec = statsData.scan_sec + statsData.transform_sec + statsData.output_sec;
- const latRate = rates.rate("lat", totalSec * 1000);
- if (latRate != null) {
- series[5].ring.push(latRate);
- series[5].value = latRate.toFixed(1);
- }
- }
+ // Batch latency: delta processing time / delta batches
+ const totalMs =
+ (statsData.scan_sec + statsData.transform_sec + statsData.output_sec) * 1000;
+ const totalMsPerSec = rates.rate("lat_ms", totalMs);
+ const batchesPerSec = rates.rate("batches", statsData.batches);
+ if (totalMsPerSec != null && batchesPerSec != null && batchesPerSec > 0) {
+ const latencyMs = totalMsPerSec / batchesPerSec;
+ series[5].ring.push(latencyMs);
+ series[5].value = latencyMs.toFixed(1);
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Batch latency: total processing time / batches | |
| if (statsData.batches > 0) { | |
| const totalSec = statsData.scan_sec + statsData.transform_sec + statsData.output_sec; | |
| const latRate = rates.rate("lat", totalSec * 1000); | |
| if (latRate != null) { | |
| series[5].ring.push(latRate); | |
| series[5].value = latRate.toFixed(1); | |
| } | |
| // Batch latency: delta processing time / delta batches | |
| const totalMs = | |
| (statsData.scan_sec + statsData.transform_sec + statsData.output_sec) * 1000; | |
| const totalMsPerSec = rates.rate("lat_ms", totalMs); | |
| const batchesPerSec = rates.rate("batches", statsData.batches); | |
| if (totalMsPerSec != null && batchesPerSec != null && batchesPerSec > 0) { | |
| const latencyMs = totalMsPerSec / batchesPerSec; | |
| series[5].ring.push(latencyMs); | |
| series[5].value = latencyMs.toFixed(1); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/app.tsx` around lines 99 - 106, The latency is being computed
as total processing ms/sec instead of ms per batch; replace the single call to
rates.rate("lat", totalSec * 1000) with a per-batch computation: get totalMsRate
= rates.rate("lat", totalSec * 1000) and batchesRate = rates.rate("batches", 1)
and compute latRate = (batchesRate && batchesRate > 0) ? totalMsRate /
batchesRate : (statsData.batches > 0 ? (totalSec * 1000) / statsData.batches :
null); then push latRate into series[5].ring and set series[5].value =
latRate.toFixed(1) only when latRate is not null (keep existing symbols
statsData.batches, totalSec, rates.rate("lat", ...), rates.rate("batches", 1),
series[5].ring, series[5].value).
Stderr capture (crates/logfwd-io/src/stderr_capture.rs):
- Activates lazily on first GET /api/logs request — zero cost
when dashboard isn't open
- dup2 redirects stderr to a pipe; reader thread tees output
to the original terminal fd AND a ring buffer (1000 lines)
- Auto-teardown after 60s idle (no /api/logs polls)
- ANSI escape codes stripped from captured lines
- Unix only (#[cfg(unix)]), no-op on Windows
API:
- GET /api/logs returns {"lines":[...],"capturing":true/false}
- GET /api/config now works (added in this commit too)
Dashboard:
- LogViewer component: collapsible "Process Logs" section
- Click to open → starts capture, polls every 2s
- Auto-scrolls to bottom on new lines
- Shows "capturing" indicator when active
Also fixes:
- CPU metrics on macOS via getrusage fallback
- Pipeline component rates use per-component RateTracker
(fixes the "-" display bug)
- Batch latency chart (scan + transform + output time)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| body.push_str(&esc(line)); | ||
| body.push('"'); | ||
| } | ||
| body.push_str(r#"],"capturing":"#); |
There was a problem hiding this comment.
/api/logs currently emits invalid JSON for the capturing field.
At this line the response appends "capturing":" and then writes true/false without a closing quote, producing payloads like {"lines":[],"capturing":"true}. This breaks res.json() in the dashboard log viewer.
Please emit capturing as a JSON boolean instead:
- prefix should be
r#"],\"capturing\":"# - then append
"true"/"false"(without surrounding JSON string quotes)
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (7)
dashboard/src/components/PipelineView.tsx (3)
67-84:⚠️ Potential issue | 🟡 MinorMissing
keyprop on Fragment for outputs.Same issue as inputs—add
keyto the Fragment wrapper.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/components/PipelineView.tsx` around lines 67 - 84, The Fragment wrapper inside the p.outputs.map callback is missing a key which can cause React rendering issues; add a unique key prop to the Fragment (e.g., key={`out-${i}`} or use out.id if available) that corresponds to the mapped item so React can track list items properly; locate the map over p.outputs in PipelineView.tsx (the JSX that renders Arrow, the div with class "pn out" and uses sel and toggle) and add the key on the surrounding Fragment element.
25-29:⚠️ Potential issue | 🟠 MajorCalling
rates.rate()andtick()during render corrupts rate calculations.Both
rate()andtick()mutate internal state. Calling them during render means every re-render (inspector toggle, selection change) will corrupt the EMA baselines and produce incorrect rates.Move rate computation to a poll loop or add a read-only getter to
RateTracker.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/components/PipelineView.tsx` around lines 25 - 29, The compRate helper is calling the mutating methods ratesRef.current.rate(...) and ratesRef.current.tick() from render (in compRate), which corrupts the EMA baselines; change this by moving rate updates out of render: either implement a polling/effect loop (useEffect) that calls ratesRef.current.rate(name, value) and ratesRef.current.tick() on an interval and writes computed formatted rates into component state (e.g., a Map keyed by `${comp.name}_${field}`) that compRate then reads, or add a non-mutating read-only accessor to RateTracker (e.g., getRate(name, currentValue) or peekRate) and use that from compRate while performing tick() only in the poll loop; update usages of compRate, ratesRef, rate(), tick(), and RateTracker accordingly.
35-49:⚠️ Potential issue | 🟡 MinorMissing
keyprop on Fragment.React/Preact requires a
keyon the outermost element in.map(). Use<Fragment key={inp.name}>or a keyed wrapper.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/components/PipelineView.tsx` around lines 35 - 49, The p.inputs.map rendering in PipelineView.tsx returns a top-level Fragment without a key, which breaks React/Preact list rendering; fix by giving that outer Fragment a stable key (e.g., key={inp.name} or key={i}), or remove the Fragment and place the key on the outer <div class="pn inp"> instead, ensuring the Arrow and the input node still render in the correct order; update the Fragment/outer element where p.inputs.map maps over inputs (the block using Arrow, the div with class "pn inp", toggle and sel) to include the key.dashboard/src/app.tsx (2)
115-119:⚠️ Potential issue | 🟠 MajorPolling loop can overlap if
poll()takes >2s.
setIntervaldoesn't await the asyncpoll(). Overlapping calls corruptRateTrackerstate. Use a self-scheduling loop.🔧 Suggested fix
useEffect(() => { - poll(); - const id = setInterval(poll, POLL_MS); - return () => clearInterval(id); + let cancelled = false; + let timeoutId: number | undefined; + + const loop = async () => { + await poll(); + if (!cancelled) timeoutId = window.setTimeout(loop, POLL_MS); + }; + + void loop(); + return () => { + cancelled = true; + if (timeoutId != null) clearTimeout(timeoutId); + }; }, [poll]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/app.tsx` around lines 115 - 119, The polling currently uses setInterval so async poll() calls can overlap and corrupt RateTracker; replace the setInterval pattern in useEffect with a self-scheduling loop that awaits poll() between iterations (e.g., an async function that while(!stopped) { await poll(); await sleep(POLL_MS); }) and cancel it on unmount by flipping a stopped flag or using an AbortController; ensure the loop creation/cleanup sits inside the same useEffect and still depends on poll so only one non-overlapping instance runs and RateTracker state remains consistent.
100-108:⚠️ Potential issue | 🟠 MajorBatch latency formula yields ms/s, not ms/batch.
rates.rate("lat", totalSec * 1000)gives the rate of change of cumulative processing time (ms per second), not latency per batch. Divide by the batch rate to get actual latency.🔧 Correct calculation
// Batch latency: total processing time / batches - if (statsData.batches > 0) { - const totalSec = statsData.scan_sec + statsData.transform_sec + statsData.output_sec; - const latRate = rates.rate("lat", totalSec * 1000); - if (latRate != null) { - series[5].ring.push(latRate); - series[5].value = latRate.toFixed(1); - } - } + const totalMs = (statsData.scan_sec + statsData.transform_sec + statsData.output_sec) * 1000; + const msPerSec = rates.rate("lat_ms", totalMs); + const batchesPerSec = rates.rate("lat_batches", statsData.batches); + if (msPerSec != null && batchesPerSec != null && batchesPerSec > 0) { + const latencyMs = msPerSec / batchesPerSec; + series[5].ring.push(latencyMs); + series[5].value = latencyMs.toFixed(1); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/app.tsx` around lines 100 - 108, The code computes a time-per-second rate with rates.rate("lat", totalSec * 1000) instead of time-per-batch; compute the batch rate and divide the latency rate by it. Specifically, call something like const batchRate = rates.rate("batches", statsData.batches), ensure batchRate > 0, then compute const latPerBatch = latRate / batchRate and push/assign latPerBatch (not latRate) to series[5].ring and series[5].value (formatting with toFixed). Keep the existing guards (statsData.batches > 0 and latRate != null) and add a guard for batchRate being non-null and > 0.crates/logfwd-io/src/dashboard.html (1)
1-15:⚠️ Potential issue | 🟠 MajorGenerated artifact—issues should be fixed in source components.
The keyboard accessibility issue (pipeline cards using
<div onClick>instead of<button>) must be fixed in the source (dashboard/src/components/PipelineView.tsx) and this file regenerated.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/logfwd-io/src/dashboard.html` around lines 1 - 15, The pipeline cards are rendered as clickable <div onClick> elements (see the Pe component and the PN elements with class "pn" / pn.inp / pn.xfm / pn.out) which breaks keyboard accessibility; update the source PipelineView component (the Pe function) to use semantic interactive elements (replace those clickable <div class="pn"> blocks with <button type="button"> or a styled <button>), move the onClick handlers to the button (remove reliance on onKeyDown or role hacks), preserve existing class names/styles so visuals are unchanged, add any necessary aria-labels or aria-pressed state if selection is meaningful, then rebuild/regenerate this dashboard.html artifact.crates/logfwd-io/src/diagnostics.rs (1)
534-548:⚠️ Potential issue | 🔴 CriticalRaw config exposed via
/api/configleaks credentials.
serve_configreturnsconfig_yamlverbatim—any secrets (API keys, passwords, file paths) are exposed over the unauthenticated diagnostics port. Either redact sensitive fields or gate behind explicit opt-in.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/logfwd-io/src/diagnostics.rs` around lines 534 - 548, serve_config currently returns self.config_yaml verbatim which leaks secrets; change serve_config in diagnostics.rs so it never exposes raw config by default: either (A) parse self.config_yaml (YAML/JSON), recursively redact or replace values for keys matching sensitive patterns (password, secret, token, key, api, access, private, secret_access_key, etc.) and serialize the redacted payload instead of raw config_yaml, or (B) gate the endpoint behind an explicit opt-in flag on the diagnostics struct (e.g., self.allow_expose_config) and return an error/403 when the flag is false; update the response body to include only esc(&self.config_path) plus the redacted config or a denial message and ensure serve_config, config_yaml, and config_path references are used to locate and implement the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/logfwd-io/src/stderr_capture.rs`:
- Around line 146-151: The thread spawn error is being dropped by calling .ok()
on std::thread::Builder::new().spawn(...), so if the reader thread fails to
start stderr stays redirected and the pipe reader is never run; change this to
handle the Result from spawn, e.g. match or if let Err(e) =
std::thread::Builder::new().name("stderr-capture".into()).spawn(move || {
reader_loop(read_fd, orig, &state); }) { log or write the error to the original
stderr (orig) and restore stderr to orig (undo the redirection) and perform any
cleanup/early return }; on Ok(_) continue as before. Ensure you reference
reader_loop, read_fd, orig, and state when implementing the error handling and
restore logic.
---
Duplicate comments:
In `@crates/logfwd-io/src/dashboard.html`:
- Around line 1-15: The pipeline cards are rendered as clickable <div onClick>
elements (see the Pe component and the PN elements with class "pn" / pn.inp /
pn.xfm / pn.out) which breaks keyboard accessibility; update the source
PipelineView component (the Pe function) to use semantic interactive elements
(replace those clickable <div class="pn"> blocks with <button type="button"> or
a styled <button>), move the onClick handlers to the button (remove reliance on
onKeyDown or role hacks), preserve existing class names/styles so visuals are
unchanged, add any necessary aria-labels or aria-pressed state if selection is
meaningful, then rebuild/regenerate this dashboard.html artifact.
In `@crates/logfwd-io/src/diagnostics.rs`:
- Around line 534-548: serve_config currently returns self.config_yaml verbatim
which leaks secrets; change serve_config in diagnostics.rs so it never exposes
raw config by default: either (A) parse self.config_yaml (YAML/JSON),
recursively redact or replace values for keys matching sensitive patterns
(password, secret, token, key, api, access, private, secret_access_key, etc.)
and serialize the redacted payload instead of raw config_yaml, or (B) gate the
endpoint behind an explicit opt-in flag on the diagnostics struct (e.g.,
self.allow_expose_config) and return an error/403 when the flag is false; update
the response body to include only esc(&self.config_path) plus the redacted
config or a denial message and ensure serve_config, config_yaml, and config_path
references are used to locate and implement the change.
In `@dashboard/src/app.tsx`:
- Around line 115-119: The polling currently uses setInterval so async poll()
calls can overlap and corrupt RateTracker; replace the setInterval pattern in
useEffect with a self-scheduling loop that awaits poll() between iterations
(e.g., an async function that while(!stopped) { await poll(); await
sleep(POLL_MS); }) and cancel it on unmount by flipping a stopped flag or using
an AbortController; ensure the loop creation/cleanup sits inside the same
useEffect and still depends on poll so only one non-overlapping instance runs
and RateTracker state remains consistent.
- Around line 100-108: The code computes a time-per-second rate with
rates.rate("lat", totalSec * 1000) instead of time-per-batch; compute the batch
rate and divide the latency rate by it. Specifically, call something like const
batchRate = rates.rate("batches", statsData.batches), ensure batchRate > 0, then
compute const latPerBatch = latRate / batchRate and push/assign latPerBatch (not
latRate) to series[5].ring and series[5].value (formatting with toFixed). Keep
the existing guards (statsData.batches > 0 and latRate != null) and add a guard
for batchRate being non-null and > 0.
In `@dashboard/src/components/PipelineView.tsx`:
- Around line 67-84: The Fragment wrapper inside the p.outputs.map callback is
missing a key which can cause React rendering issues; add a unique key prop to
the Fragment (e.g., key={`out-${i}`} or use out.id if available) that
corresponds to the mapped item so React can track list items properly; locate
the map over p.outputs in PipelineView.tsx (the JSX that renders Arrow, the div
with class "pn out" and uses sel and toggle) and add the key on the surrounding
Fragment element.
- Around line 25-29: The compRate helper is calling the mutating methods
ratesRef.current.rate(...) and ratesRef.current.tick() from render (in
compRate), which corrupts the EMA baselines; change this by moving rate updates
out of render: either implement a polling/effect loop (useEffect) that calls
ratesRef.current.rate(name, value) and ratesRef.current.tick() on an interval
and writes computed formatted rates into component state (e.g., a Map keyed by
`${comp.name}_${field}`) that compRate then reads, or add a non-mutating
read-only accessor to RateTracker (e.g., getRate(name, currentValue) or
peekRate) and use that from compRate while performing tick() only in the poll
loop; update usages of compRate, ratesRef, rate(), tick(), and RateTracker
accordingly.
- Around line 35-49: The p.inputs.map rendering in PipelineView.tsx returns a
top-level Fragment without a key, which breaks React/Preact list rendering; fix
by giving that outer Fragment a stable key (e.g., key={inp.name} or key={i}), or
remove the Fragment and place the key on the outer <div class="pn inp"> instead,
ensuring the Arrow and the input node still render in the correct order; update
the Fragment/outer element where p.inputs.map maps over inputs (the block using
Arrow, the div with class "pn inp", toggle and sel) to include the key.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 12ee5524-044d-4bad-bce2-22f6838e81af
📒 Files selected for processing (7)
crates/logfwd-io/src/dashboard.htmlcrates/logfwd-io/src/diagnostics.rscrates/logfwd-io/src/lib.rscrates/logfwd-io/src/stderr_capture.rsdashboard/src/app.tsxdashboard/src/components/LogViewer.tsxdashboard/src/components/PipelineView.tsx
| std::thread::Builder::new() | ||
| .name("stderr-capture".into()) | ||
| .spawn(move || { | ||
| reader_loop(read_fd, orig, &state); | ||
| }) | ||
| .ok(); |
There was a problem hiding this comment.
Thread spawn failure silently ignored.
.ok() discards the spawn error. If the thread fails to start, stderr is already redirected but nothing reads the pipe—writes will eventually block. At minimum log the failure; ideally restore stderr on spawn error.
🔧 Suggested fix
std::thread::Builder::new()
.name("stderr-capture".into())
.spawn(move || {
reader_loop(read_fd, orig, &state);
})
- .ok();
+ .unwrap_or_else(|e| {
+ eprintln!("stderr-capture: failed to spawn reader thread: {e}");
+ // Restore stderr since capture failed
+ libc::dup2(orig, 2);
+ libc::close(orig);
+ libc::close(read_fd);
+ state.active.store(false, Ordering::Relaxed);
+ });Note: The eprintln! will go to the original stderr (via orig) since we restore it on failure.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/logfwd-io/src/stderr_capture.rs` around lines 146 - 151, The thread
spawn error is being dropped by calling .ok() on
std::thread::Builder::new().spawn(...), so if the reader thread fails to start
stderr stays redirected and the pipe reader is never run; change this to handle
the Result from spawn, e.g. match or if let Err(e) =
std::thread::Builder::new().name("stderr-capture".into()).spawn(move || {
reader_loop(read_fd, orig, &state); }) { log or write the error to the original
stderr (orig) and restore stderr to orig (undo the redirection) and perform any
cleanup/early return }; on Ok(_) continue as before. Ensure you reference
reader_loop, read_fd, orig, and state when implementing the error handling and
restore logic.
- Moved from bottom of page to right after metrics charts - Collapsed state: dashed-border card with terminal icon and "Stream process logs" prompt — clearly clickable - Expanded state: proper log box with header bar (icon, title, live indicator, line count, close button) - CSS in style.css instead of inline styles Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Requesting changes due to a confirmed async polling race that can render stale log data out of order.
Note
🔒 Integrity filtering filtered 1 item
Integrity filtering activated and filtered the following item during workflow execution.
This happens when a tool call accesses a resource that does not meet the required integrity or secrecy level of the workflow.
- pr:#526 (
pull_request_read: has lower integrity than agent requires. The agent cannot read data with integrity below "approved".)
What is this? | From workflow: AI: PR Review
Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.
| }; | ||
|
|
||
| poll(); | ||
| const id = setInterval(poll, 2000); |
There was a problem hiding this comment.
setInterval(poll, 2000) can start a new /api/logs request before the previous one finishes. Because poll is async and unguarded, slower older responses can arrive after newer ones and overwrite state via setLogs(data.lines), causing log output to jump backward/flicker.
Please serialize polling (e.g., self-scheduling setTimeout after await poll()), or guard against out-of-order updates (request sequence/in-flight flag).
Bugs fixed: - RateTracker now uses per-key timestamps instead of shared prevTime — fixes rate calculations showing 0 for all but first component in PipelineView - tick() no longer needed (deprecated, no-op) Dead code removed: - SystemRow.tsx (unused component, replaced by MetricBadges) - SystemResponse type (never referenced) - fmtPercent function (never imported) - .sys-row CSS rules (only used by dead SystemRow) Log viewer UX: - Always-visible card with "▶ Stream logs" button under metrics - Header bar with icon, live indicator, line count, close button - Proper CSS classes instead of inline styles Accessibility: - Log viewer close button: aria-label="Close log viewer" - Stream logs button: role="button", tabIndex, keyboard handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (6)
dashboard/src/components/LogViewer.tsx (2)
41-52:⚠️ Potential issue | 🟠 MajorUse a real button for the log opener.
The clickable
divat Line 41 is mouse-only. Keyboard users cannot open the panel from this control.Suggested fix
- <div + <button + type="button" class="log-prompt" onClick={() => setOpen(true)} > @@ - </div> + </button>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/components/LogViewer.tsx` around lines 41 - 52, The log opener is a non-interactive div which is not keyboard accessible; replace the <div class="log-prompt" onClick={() => setOpen(true)}> opener in LogViewer with a semantic <button> (or a component that renders a button) so it supports keyboard/aria interaction, keep the existing onClick handler bound to setOpen, preserve SVG and inner spans, and add an accessible label/aria-label (e.g., "Open logs") and type="button" so screen readers and keyboard users can open the panel.
14-33:⚠️ Potential issue | 🟠 MajorSerialize the
/api/logspoller.This
setIntervalcan overlap async fetches. A slow response can arrive after a newer one and rewind the panel; cleanup also leaves the in-flight request running.Suggested fix
useEffect(() => { if (!open) return; + let cancelled = false; + let timeoutId: number | undefined; + const poll = async () => { try { const res = await fetch("/api/logs"); if (res.ok) { const data: LogsResponse = await res.json(); + if (cancelled) return; setLogs(data.lines); setCapturing(data.capturing); } } catch { // ignore } }; - poll(); - const id = setInterval(poll, 2000); - return () => clearInterval(id); + const loop = async () => { + while (!cancelled) { + await poll(); + if (cancelled) return; + await new Promise<void>((resolve) => { + timeoutId = window.setTimeout(resolve, 2000); + }); + } + }; + + void loop(); + return () => { + cancelled = true; + if (timeoutId != null) clearTimeout(timeoutId); + }; }, [open]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/components/LogViewer.tsx` around lines 14 - 33, The poller in useEffect (the poll function that fetches "/api/logs" and calls setLogs/setCapturing) can overlap requests and allow stale responses to overwrite newer state; modify poll to serialize requests by creating and using an AbortController for each fetch and tracking an in-flight flag (or simple mutex) so you skip starting a new fetch while one is pending, and on cleanup abort any in-flight request via controller.abort(); ensure only responses from the current non-aborted fetch update state (check controller.signal.aborted or the in-flight flag before calling setLogs/setCapturing) and keep using setInterval/clearInterval for scheduling.dashboard/src/app.tsx (2)
100-107:⚠️ Potential issue | 🟠 MajorBatch latency is still computed as ms/sec.
Line 103 samples total processing time as a rate and publishes it directly. That measures processing milliseconds per second, not milliseconds per batch, so the card inflates with throughput.
Suggested fix
- // Batch latency: total processing time / batches - if (statsData.batches > 0) { - const totalSec = statsData.scan_sec + statsData.transform_sec + statsData.output_sec; - const latRate = rates.rate("lat", totalSec * 1000); - if (latRate != null) { - series[5].ring.push(latRate); - series[5].value = latRate.toFixed(1); - } - } + const totalMs = + (statsData.scan_sec + statsData.transform_sec + statsData.output_sec) * 1000; + const totalMsPerSec = rates.rate("lat_ms", totalMs); + const batchesPerSec = rates.rate("batches", statsData.batches); + if (totalMsPerSec != null && batchesPerSec != null && batchesPerSec > 0) { + const latencyMs = totalMsPerSec / batchesPerSec; + series[5].ring.push(latencyMs); + series[5].value = latencyMs.toFixed(1); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/app.tsx` around lines 100 - 107, The batch latency calculation is using total processing time in ms per second instead of ms per batch; update the computation so you divide the total time (totalSec * 1000) by statsData.batches before calling rates.rate("lat", ...). In other words, compute milliseconds per batch from statsData (using totalSec and statsData.batches), pass that per-batch value into rates.rate, and then use the returned latRate to push into series[5].ring and set series[5].value.
115-118:⚠️ Potential issue | 🟠 MajorSerialize the main poll loop.
setIntervalcan start a newpoll()before the previous one finishes. That lets older responses clobber newer state and makesrates.tick()run out of order.Suggested fix
useEffect(() => { - poll(); - const id = setInterval(poll, POLL_MS); - return () => clearInterval(id); + let cancelled = false; + let timeoutId: number | undefined; + + const loop = async () => { + while (!cancelled) { + await poll(); + if (cancelled) return; + await new Promise<void>((resolve) => { + timeoutId = window.setTimeout(resolve, POLL_MS); + }); + } + }; + + void loop(); + return () => { + cancelled = true; + if (timeoutId != null) clearTimeout(timeoutId); + }; }, [poll]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/app.tsx` around lines 115 - 118, The current poll loop uses setInterval which can call poll() again before the prior async invocation finishes, causing out-of-order state updates (e.g., rates.tick()). Fix by serializing poll calls: either replace the setInterval logic with an async recursive loop that awaits poll() then waits POLL_MS via setTimeout (start by calling runPollLoop() which awaits poll() then delays and repeats), or add a running lock inside poll() (e.g., a boolean or mutex in the component scope checked at start of poll and returned immediately if already running) so concurrent executions cannot overlap; update references to poll() and rates.tick() accordingly.dashboard/src/style.css (1)
19-19:⚠️ Potential issue | 🟠 MajorQuote the font-family names in
--sans.Line 19 still fails
value-keyword-caseforBlinkMacSystemFontandRoboto. Quote them here, then regeneratecrates/logfwd-io/src/dashboard.html.Suggested fix
- --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --sans: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/style.css` at line 19, The CSS variable --sans currently lists unquoted font-family names causing value-keyword-case errors; update the declaration of --sans to wrap BlinkMacSystemFont and Roboto in quotes (i.e., quote those font names in the --sans variable in style.css where --sans is defined), then regenerate the dashboard HTML output (crates/logfwd-io/src/dashboard.html) so the compiled HTML reflects the fixed CSS.crates/logfwd-io/src/dashboard.html (1)
8-8:⚠️ Potential issue | 🟠 MajorThe shipped bundle still uses clickable pipeline
divs.This file is served as-is, and the compiled
.pnnodes are stilldiv+onClick, so keyboard users cannot open inspectors. Fixdashboard/src/components/PipelineView.tsxin source and regenerate this artifact.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/logfwd-io/src/dashboard.html` at line 8, The compiled bundle shows pipeline nodes rendered as <div> elements with onClick handlers (see produced class `pn` and functions Pe/oe/Be), which are not keyboard accessible; update the source component PipelineView (dashboard/src/components/PipelineView.tsx) to render interactive pipeline items as semantic <button> (or an element with role="button", tabindex="0") instead of plain <div>, move the onClick into an onClick/onKeyDown pair that triggers the same handler (e.g., handleSelect / i => n(...)), add appropriate aria-label/aria-pressed or aria-expanded as needed, ensure styles for `.pn` still apply to the new element, then rebuild/regenerate the dashboard HTML so the bundle no longer emits clickable-only divs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@dashboard/src/app.tsx`:
- Around line 28-36: The memory series in createSeries currently uses id "mem"
which doesn't match the byte-formatted cases in Chart.tsx; update the series
created in createSeries (function createSeries) to use the id that Chart.tsx
recognizes for memory bytes (replace id "mem" with "rss") so the Chart component
will apply the byte-scaling formatter to memory values and keep the
label/color/unit as intended.
In `@dashboard/src/components/LogViewer.tsx`:
- Around line 35-37: The auto-follow effect currently depends on logs.length
which stops firing once the buffer caps at 1000; update the effect so it watches
the logs array itself (or a monotonic update token you increment when snapshots
arrive) instead of logs.length — locate the useEffect that calls
bottomRef.current?.scrollIntoView({ behavior: "smooth" }) and change its
dependency from [logs.length] to [logs] (or to a logsVersion/update counter you
maintain when you setLogs) so new snapshots still trigger scrolling even when
length doesn't change.
---
Duplicate comments:
In `@crates/logfwd-io/src/dashboard.html`:
- Line 8: The compiled bundle shows pipeline nodes rendered as <div> elements
with onClick handlers (see produced class `pn` and functions Pe/oe/Be), which
are not keyboard accessible; update the source component PipelineView
(dashboard/src/components/PipelineView.tsx) to render interactive pipeline items
as semantic <button> (or an element with role="button", tabindex="0") instead of
plain <div>, move the onClick into an onClick/onKeyDown pair that triggers the
same handler (e.g., handleSelect / i => n(...)), add appropriate
aria-label/aria-pressed or aria-expanded as needed, ensure styles for `.pn`
still apply to the new element, then rebuild/regenerate the dashboard HTML so
the bundle no longer emits clickable-only divs.
In `@dashboard/src/app.tsx`:
- Around line 100-107: The batch latency calculation is using total processing
time in ms per second instead of ms per batch; update the computation so you
divide the total time (totalSec * 1000) by statsData.batches before calling
rates.rate("lat", ...). In other words, compute milliseconds per batch from
statsData (using totalSec and statsData.batches), pass that per-batch value into
rates.rate, and then use the returned latRate to push into series[5].ring and
set series[5].value.
- Around line 115-118: The current poll loop uses setInterval which can call
poll() again before the prior async invocation finishes, causing out-of-order
state updates (e.g., rates.tick()). Fix by serializing poll calls: either
replace the setInterval logic with an async recursive loop that awaits poll()
then waits POLL_MS via setTimeout (start by calling runPollLoop() which awaits
poll() then delays and repeats), or add a running lock inside poll() (e.g., a
boolean or mutex in the component scope checked at start of poll and returned
immediately if already running) so concurrent executions cannot overlap; update
references to poll() and rates.tick() accordingly.
In `@dashboard/src/components/LogViewer.tsx`:
- Around line 41-52: The log opener is a non-interactive div which is not
keyboard accessible; replace the <div class="log-prompt" onClick={() =>
setOpen(true)}> opener in LogViewer with a semantic <button> (or a component
that renders a button) so it supports keyboard/aria interaction, keep the
existing onClick handler bound to setOpen, preserve SVG and inner spans, and add
an accessible label/aria-label (e.g., "Open logs") and type="button" so screen
readers and keyboard users can open the panel.
- Around line 14-33: The poller in useEffect (the poll function that fetches
"/api/logs" and calls setLogs/setCapturing) can overlap requests and allow stale
responses to overwrite newer state; modify poll to serialize requests by
creating and using an AbortController for each fetch and tracking an in-flight
flag (or simple mutex) so you skip starting a new fetch while one is pending,
and on cleanup abort any in-flight request via controller.abort(); ensure only
responses from the current non-aborted fetch update state (check
controller.signal.aborted or the in-flight flag before calling
setLogs/setCapturing) and keep using setInterval/clearInterval for scheduling.
In `@dashboard/src/style.css`:
- Line 19: The CSS variable --sans currently lists unquoted font-family names
causing value-keyword-case errors; update the declaration of --sans to wrap
BlinkMacSystemFont and Roboto in quotes (i.e., quote those font names in the
--sans variable in style.css where --sans is defined), then regenerate the
dashboard HTML output (crates/logfwd-io/src/dashboard.html) so the compiled HTML
reflects the fixed CSS.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: a63a48e3-47cc-4b13-8efe-271e9489d465
📒 Files selected for processing (4)
crates/logfwd-io/src/dashboard.htmldashboard/src/app.tsxdashboard/src/components/LogViewer.tsxdashboard/src/style.css
| function createSeries(): MetricSeries[] { | ||
| return [ | ||
| { id: "lps", label: "Lines / sec", color: "#3b82f6", ring: new RingBuffer(), value: "-", unit: "/s" }, | ||
| { id: "bps", label: "Input Bytes", color: "#8b5cf6", ring: new RingBuffer(), value: "-", unit: "/s" }, | ||
| { id: "err", label: "Errors / sec", color: "#ef4444", ring: new RingBuffer(), value: "-", unit: "/s" }, | ||
| { id: "cpu", label: "Process CPU", color: "#f59e0b", ring: new RingBuffer(), value: "-", unit: "%" }, | ||
| { id: "mem", label: "Memory", color: "#10b981", ring: new RingBuffer(), value: "-", unit: "" }, | ||
| { id: "lat", label: "Batch Latency", color: "#06b6d4", ring: new RingBuffer(), value: "-", unit: "ms" }, | ||
| ]; |
There was a problem hiding this comment.
Align the memory series contract with Chart.
Line 34 uses id: "mem", but dashboard/src/components/Chart.tsx only treats "bps" and "rss" as byte-formatted series. The Memory chart Y axis will therefore use the generic numeric formatter instead of byte scaling.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/app.tsx` around lines 28 - 36, The memory series in
createSeries currently uses id "mem" which doesn't match the byte-formatted
cases in Chart.tsx; update the series created in createSeries (function
createSeries) to use the id that Chart.tsx recognizes for memory bytes (replace
id "mem" with "rss") so the Chart component will apply the byte-scaling
formatter to memory values and keep the label/color/unit as intended.
| useEffect(() => { | ||
| bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | ||
| }, [logs.length]); |
There was a problem hiding this comment.
Auto-follow stops once the log buffer fills.
stderr_capture caps the server buffer at 1000 lines. After that, new snapshots still have logs.length === 1000, so this effect stops firing and the viewer no longer follows live output.
Suggested fix
+ const scrollKey = `${logs.length}:${logs[0] ?? ""}:${logs[logs.length - 1] ?? ""}`;
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [logs.length]);
+ }, [scrollKey]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [logs.length]); | |
| const scrollKey = `${logs.length}:${logs[0] ?? ""}:${logs[logs.length - 1] ?? ""}`; | |
| useEffect(() => { | |
| bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [scrollKey]); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/components/LogViewer.tsx` around lines 35 - 37, The auto-follow
effect currently depends on logs.length which stops firing once the buffer caps
at 1000; update the effect so it watches the logs array itself (or a monotonic
update token you increment when snapshots arrive) instead of logs.length —
locate the useEffect that calls bottomRef.current?.scrollIntoView({ behavior:
"smooth" }) and change its dependency from [logs.length] to [logs] (or to a
logsVersion/update counter you maintain when you setLogs) so new snapshots still
trigger scrolling even when length doesn't change.
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (7)
dashboard/src/lib/format.ts (2)
31-33:⚠️ Potential issue | 🟡 MinorUse a visible missing-value token for null duration.
On Line 32, returning
""can render as a blank field and is inconsistent with other formatters using"-".Proposed fix
export function fmtDuration(s: number | null | undefined): string { - if (s == null) return ""; + if (s == null) return "-"; if (s >= 86400) return Math.floor(s / 86400) + "d " + Math.floor((s % 86400) / 3600) + "h"; if (s >= 3600) return Math.floor(s / 3600) + "h " + Math.floor((s % 3600) / 60) + "m"; if (s >= 60) return Math.floor(s / 60) + "m " + Math.floor(s % 60) + "s"; return s + "s"; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/lib/format.ts` around lines 31 - 33, The fmtDuration function returns an empty string for null/undefined which renders blank; change the missing-value token to "-" to match other formatters by updating the null check in fmtDuration (function name: fmtDuration) to return "-" instead of "" so null/undefined durations show a visible placeholder; leave all other formatting logic unchanged.
16-21:⚠️ Potential issue | 🟡 MinorDo not treat
0bytes as missing.On Line 17,
b === 0returning"-"hides valid zero-byte values as absent data.Proposed fix
export function fmtBytes(b: number | null | undefined): string { - if (b == null || b === 0) return "-"; + if (b == null) return "-"; + if (b === 0) return "0 B"; if (b >= 1073741824) return (b / 1073741824).toFixed(1) + " GB"; if (b >= 1048576) return (b / 1048576).toFixed(1) + " MB"; if (b >= 1024) return (b / 1024).toFixed(1) + " KB"; return b + " B"; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/lib/format.ts` around lines 16 - 21, The fmtBytes function incorrectly treats 0 as missing; update the null-check in fmtBytes to only consider null/undefined (remove the b === 0 check) so zero bytes are formatted as "0 B" instead of "-"—locate the fmtBytes function and change the guard from `if (b == null || b === 0) return "-"` to `if (b == null) return "-"`, leaving the rest of the size-branching logic unchanged.dashboard/src/components/PipelineView.tsx (2)
25-28:⚠️ Potential issue | 🟠 MajorRender-time
rate()call mutates tracker state.
RateTracker.rate()updates internal maps on every call. Invoking it during render (viacompRate) corrupts tracking when re-renders occur between polls—subsequent calls see dt≈0 or wrong deltas.Move rate computation to the poll cycle, or add a read-only
getRate(key)accessor that returns cached EMA without mutation.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/components/PipelineView.tsx` around lines 25 - 28, compRate is calling RateTracker.rate() during render which mutates tracker state; stop calling ratesRef.current.rate() from compRate and instead compute rates during the polling/update cycle (where you already call the tracker) and store the computed fmt values in component state or memo so render only reads them, or add a read-only accessor getRate(key) on RateTracker that returns the cached EMA without mutating maps and have compRate read from ratesRef.current.getRate(`${comp.name}_${field}`); update usages of compRate to read the precomputed or read-only values and remove any direct rate() invocations from render paths.
34-48:⚠️ Potential issue | 🟡 MinorMissing
keyon Fragment in map iteration.Preact/React requires keys on the outermost element returned from
.map(). The Fragment<>lacks a key, which can cause reconciliation bugs.🔧 Fix
+import { Fragment } from "preact"; // ... {p.inputs.map((inp, i) => ( - <> + <Fragment key={inp.name}> {i > 0 && <Arrow />} <div ...> - </> + </Fragment> ))}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/components/PipelineView.tsx` around lines 34 - 48, The map callback returns an unkeyed Fragment which breaks reconciliation; add a stable key to the outer element returned by p.inputs.map — either give the Fragment a key (e.g., use <Fragment key={`i${i}`}> and import Fragment from Preact/React) or remove the Fragment and move a key onto the outermost <div class={`pn inp ...`}> (prefer using a stable identifier like inp.id if available rather than the index). Ensure the key value matches the selection id pattern (`i${i}`) if you keep using the index-based IDs.dashboard/src/components/LogViewer.tsx (2)
35-37:⚠️ Potential issue | 🟡 MinorAuto-scroll breaks once log buffer caps at 1000 lines.
The server caps stderr at 1000 lines. Once full,
logs.lengthstays constant and this effect stops firing—new content won't scroll into view.🔧 Use content fingerprint instead of length
+ const scrollKey = logs.length > 0 + ? `${logs.length}:${logs[logs.length - 1]}` + : ""; + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [logs.length]); + }, [scrollKey]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/components/LogViewer.tsx` around lines 35 - 37, The effect in LogViewer uses [logs.length] so when the server caps stderr at 1000 lines the dependency stops changing and auto-scroll breaks; change the dependency to a content fingerprint that changes when new log content arrives (e.g., compute a memoized key from logs such as the last log's id/timestamp or a short hash/JSON of the logs via useMemo/useState) and use that fingerprint in the useEffect dependency instead of logs.length; update the useEffect in the LogViewer component that references bottomRef.current?.scrollIntoView to depend on the new fingerprint (and ensure the fingerprint updates whenever logs change, even if length is constant).
14-33:⚠️ Potential issue | 🟠 MajorPolling race condition: overlapping requests can cause state jumps.
setInterval(poll, 2000)fires every 2s regardless of whether the previouspoll()completed. If a slow response arrives after a newer one,setLogs(data.lines)overwrites fresh data with stale data, causing visible flicker/jumping.🔧 Self-scheduling setTimeout pattern
useEffect(() => { if (!open) return; + let cancelled = false; const poll = async () => { try { const res = await fetch("/api/logs"); if (res.ok) { const data: LogsResponse = await res.json(); - setLogs(data.lines); - setCapturing(data.capturing); + if (!cancelled) { + setLogs(data.lines); + setCapturing(data.capturing); + } } } catch { // ignore } + if (!cancelled) setTimeout(poll, 2000); }; poll(); - const id = setInterval(poll, 2000); - return () => clearInterval(id); + return () => { cancelled = true; }; }, [open]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/components/LogViewer.tsx` around lines 14 - 33, The current LogViewer useEffect uses setInterval to call poll which can overlap and let slow responses overwrite newer state; replace the setInterval pattern in the LogViewer component with a self-scheduling async loop: create an async function (e.g., pollLoop) that awaits fetch("/api/logs"), updates setLogs/setCapturing only when still mounted, then waits 2000ms before re-running (or uses setTimeout to schedule the next iteration), and expose a cancellation flag or AbortController in the useEffect cleanup to stop the loop; alternatively add a monotonically increasing requestId inside poll and ignore responses whose id is less than the latest to prevent stale responses from applying (ensure cleanup clears any pending timeout/abort).dashboard/src/style.css (1)
19-19:⚠️ Potential issue | 🟡 MinorQuote font family names to satisfy stylelint.
Stylelint's
value-keyword-caserule flags unquotedBlinkMacSystemFontandRoboto.🔧 Fix
- --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --sans: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@dashboard/src/style.css` at line 19, The CSS variable --sans currently lists unquoted font family identifiers that trip stylelint; update the variable declaration (the --sans custom property) to quote the BlinkMacSystemFont and Roboto family names (and any other multi-word or non-standard identifiers if flagged) so the value becomes valid for stylelint's value-keyword-case rule—i.e., edit the --sans declaration to use quoted names like "BlinkMacSystemFont" and "Roboto" while preserving the existing order and other values.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@dashboard/src/components/PipelineView.tsx`:
- Around line 37-46: Clickable pipeline node elements (the divs with class "pn"
in PipelineView using sel and toggle, and similar transform/output node blocks)
are not keyboard-accessible; update them to be semantic buttons or add proper
keyboard support: replace the interactive <div class="pn"> nodes with <button>
elements or add role="button", tabIndex={0}, and an onKeyDown handler that
invokes toggle when Enter/Space is pressed, and ensure aria-pressed or
aria-selected reflects the sel state; apply the same change to the transform and
output node blocks so keyboard users can focus and activate nodes.
In `@dashboard/src/style.css`:
- Around line 232-248: The .pn pipeline node styles lack a :focus-visible rule
for keyboard users; add a .pn:focus-visible selector that applies an accessible
focus ring (e.g., outline and outline-offset using --accent) so keyboard focus
is visible and consistent with .pn.selected and :hover states; update the CSS
near the .pn, .pn:hover, and .pn.selected rules to include this new
.pn:focus-visible rule to ensure keyboard accessibility.
In `@dashboard/src/types.ts`:
- Around line 19-29: The PipelineData interface is out of sync with the
/api/pipelines response: update the PipelineData type to replace the scalar
fields batches, scan_sec, transform_sec, output_sec and backpressure_stalls with
the actual nested shapes returned by the API — i.e., change batches from number
to an object with properties total, avg_rows, flush_by_size, flush_by_timeout,
dropped_batches_total, scan_errors_total; remove backpressure_stalls; and
replace scan_sec/transform_sec/output_sec with a stage_seconds object containing
scan, transform, and output numeric fields (keep name PipelineData and adjust
any usages expecting the old flat fields).
---
Duplicate comments:
In `@dashboard/src/components/LogViewer.tsx`:
- Around line 35-37: The effect in LogViewer uses [logs.length] so when the
server caps stderr at 1000 lines the dependency stops changing and auto-scroll
breaks; change the dependency to a content fingerprint that changes when new log
content arrives (e.g., compute a memoized key from logs such as the last log's
id/timestamp or a short hash/JSON of the logs via useMemo/useState) and use that
fingerprint in the useEffect dependency instead of logs.length; update the
useEffect in the LogViewer component that references
bottomRef.current?.scrollIntoView to depend on the new fingerprint (and ensure
the fingerprint updates whenever logs change, even if length is constant).
- Around line 14-33: The current LogViewer useEffect uses setInterval to call
poll which can overlap and let slow responses overwrite newer state; replace the
setInterval pattern in the LogViewer component with a self-scheduling async
loop: create an async function (e.g., pollLoop) that awaits fetch("/api/logs"),
updates setLogs/setCapturing only when still mounted, then waits 2000ms before
re-running (or uses setTimeout to schedule the next iteration), and expose a
cancellation flag or AbortController in the useEffect cleanup to stop the loop;
alternatively add a monotonically increasing requestId inside poll and ignore
responses whose id is less than the latest to prevent stale responses from
applying (ensure cleanup clears any pending timeout/abort).
In `@dashboard/src/components/PipelineView.tsx`:
- Around line 25-28: compRate is calling RateTracker.rate() during render which
mutates tracker state; stop calling ratesRef.current.rate() from compRate and
instead compute rates during the polling/update cycle (where you already call
the tracker) and store the computed fmt values in component state or memo so
render only reads them, or add a read-only accessor getRate(key) on RateTracker
that returns the cached EMA without mutating maps and have compRate read from
ratesRef.current.getRate(`${comp.name}_${field}`); update usages of compRate to
read the precomputed or read-only values and remove any direct rate()
invocations from render paths.
- Around line 34-48: The map callback returns an unkeyed Fragment which breaks
reconciliation; add a stable key to the outer element returned by p.inputs.map —
either give the Fragment a key (e.g., use <Fragment key={`i${i}`}> and import
Fragment from Preact/React) or remove the Fragment and move a key onto the
outermost <div class={`pn inp ...`}> (prefer using a stable identifier like
inp.id if available rather than the index). Ensure the key value matches the
selection id pattern (`i${i}`) if you keep using the index-based IDs.
In `@dashboard/src/lib/format.ts`:
- Around line 31-33: The fmtDuration function returns an empty string for
null/undefined which renders blank; change the missing-value token to "-" to
match other formatters by updating the null check in fmtDuration (function name:
fmtDuration) to return "-" instead of "" so null/undefined durations show a
visible placeholder; leave all other formatting logic unchanged.
- Around line 16-21: The fmtBytes function incorrectly treats 0 as missing;
update the null-check in fmtBytes to only consider null/undefined (remove the b
=== 0 check) so zero bytes are formatted as "0 B" instead of "-"—locate the
fmtBytes function and change the guard from `if (b == null || b === 0) return
"-"` to `if (b == null) return "-"`, leaving the rest of the size-branching
logic unchanged.
In `@dashboard/src/style.css`:
- Line 19: The CSS variable --sans currently lists unquoted font family
identifiers that trip stylelint; update the variable declaration (the --sans
custom property) to quote the BlinkMacSystemFont and Roboto family names (and
any other multi-word or non-standard identifiers if flagged) so the value
becomes valid for stylelint's value-keyword-case rule—i.e., edit the --sans
declaration to use quoted names like "BlinkMacSystemFont" and "Roboto" while
preserving the existing order and other values.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: c498051c-4b1c-44e1-ac95-3e57d2a8994b
📒 Files selected for processing (7)
crates/logfwd-io/src/dashboard.htmldashboard/src/components/LogViewer.tsxdashboard/src/components/PipelineView.tsxdashboard/src/lib/format.tsdashboard/src/lib/rates.tsdashboard/src/style.cssdashboard/src/types.ts
| <div | ||
| class={`pn inp ${sel === `i${i}` ? "selected" : ""}`} | ||
| onClick={() => toggle(`i${i}`)} | ||
| > | ||
| <div class="pn-type">Input</div> | ||
| <div class="pn-name">{inp.name}</div> | ||
| <div class="pn-row"><span>lines</span><b>{fmt(inp.lines_total)}</b></div> | ||
| <div class="pn-row"><span>bytes</span><b>{fmtBytes(inp.bytes_total)}</b></div> | ||
| <div class="pn-row"><span>rate</span><b>{compRate(inp, "lines_total")}</b></div> | ||
| </div> |
There was a problem hiding this comment.
Pipeline nodes lack keyboard accessibility.
The .pn divs are clickable but unreachable via keyboard. Add button semantics or use actual <button> elements.
🔧 Option: Add keyboard support to divs
<div
class={`pn inp ${sel === `i${i}` ? "selected" : ""}`}
onClick={() => toggle(`i${i}`)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') toggle(`i${i}`); }}
>Apply similarly to transform and output nodes.
Also applies to: 69-81
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/components/PipelineView.tsx` around lines 37 - 46, Clickable
pipeline node elements (the divs with class "pn" in PipelineView using sel and
toggle, and similar transform/output node blocks) are not keyboard-accessible;
update them to be semantic buttons or add proper keyboard support: replace the
interactive <div class="pn"> nodes with <button> elements or add role="button",
tabIndex={0}, and an onKeyDown handler that invokes toggle when Enter/Space is
pressed, and ensure aria-pressed or aria-selected reflects the sel state; apply
the same change to the transform and output node blocks so keyboard users can
focus and activate nodes.
| .pn { | ||
| flex: 1; | ||
| min-width: 140px; | ||
| background: var(--card); | ||
| border: 1px solid var(--border); | ||
| border-radius: var(--r); | ||
| padding: 10px 12px; | ||
| cursor: pointer; | ||
| transition: all 0.15s; | ||
| } | ||
| .pn:hover { | ||
| border-color: var(--t4); | ||
| background: var(--card-hover); | ||
| } | ||
| .pn.selected { | ||
| border-color: var(--accent); | ||
| box-shadow: 0 0 0 1px var(--accent); |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Pipeline node styles need :focus-visible for keyboard users.
With keyboard support added to .pn elements, add focus styling:
.pn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/style.css` around lines 232 - 248, The .pn pipeline node styles
lack a :focus-visible rule for keyboard users; add a .pn:focus-visible selector
that applies an accessible focus ring (e.g., outline and outline-offset using
--accent) so keyboard focus is visible and consistent with .pn.selected and
:hover states; update the CSS near the .pn, .pn:hover, and .pn.selected rules to
include this new .pn:focus-visible rule to ensure keyboard accessibility.
| export interface PipelineData { | ||
| name: string; | ||
| inputs: ComponentData[]; | ||
| transform: TransformData; | ||
| outputs: ComponentData[]; | ||
| batches?: number; | ||
| scan_sec?: number; | ||
| transform_sec?: number; | ||
| output_sec?: number; | ||
| backpressure_stalls?: number; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify the actual /api/pipelines response structure from backend
rg -A 30 'fn serve_pipelines' crates/logfwd-io/src/diagnostics.rs | head -60Repository: strawgate/memagent
Length of output: 1186
🏁 Script executed:
#!/bin/bash
# Get the complete serve_pipelines function to see response structure
rg -A 100 'fn serve_pipelines' crates/logfwd-io/src/diagnostics.rs | grep -A 80 'stage_seconds\|batches\|scan_sec\|output_sec\|transform_sec\|backpressure'Repository: strawgate/memagent
Length of output: 2603
PipelineData interface does not match the actual /api/pipelines response structure.
The backend returns:
batchesas a nested object withtotal,avg_rows,flush_by_size,flush_by_timeout,dropped_batches_total,scan_errors_total— not a single number- Timing data nested in
stage_secondsobject withscan,transform,output— not flatscan_sec,transform_sec,output_secfields - No
backpressure_stallsfield in the response
Update PipelineData to match the actual response structure or the frontend will fail to deserialize the API response correctly.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@dashboard/src/types.ts` around lines 19 - 29, The PipelineData interface is
out of sync with the /api/pipelines response: update the PipelineData type to
replace the scalar fields batches, scan_sec, transform_sec, output_sec and
backpressure_stalls with the actual nested shapes returned by the API — i.e.,
change batches from number to an object with properties total, avg_rows,
flush_by_size, flush_by_timeout, dropped_batches_total, scan_errors_total;
remove backpressure_stalls; and replace scan_sec/transform_sec/output_sec with a
stage_seconds object containing scan, transform, and output numeric fields (keep
name PipelineData and adjust any usages expecting the old flat fields).
Metric history (crates/logfwd-io/src/metric_history.rs):
- Reducing-precision ring buffer: 2s/5min, 10s/10min, 30s/45min
- ~300 points per metric, ~24KB total for all metrics
- Points auto-downsample to lower tiers as they age
Background sampler (diagnostics.rs):
- Dedicated "metric-sampler" thread records every 2s
- Samples: input/output lines/bytes/errors, scan/transform/output
timing, RSS, CPU user+sys ms, jemalloc allocated/resident
- Runs from startup — history available even before dashboard opens
GET /api/history:
- Returns all metric histories as {name: [[t,v], ...], ...}
- Dashboard can load 1 hour of history on page open
Also: added uPlot + @preact/signals to dashboard deps (not yet
wired into components — next commit).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
073f6f4 to
68d7b2b
Compare
Charts: - Replaced hand-rolled Canvas with uPlot (~15KB) — proper time-series charts with built-in cursor, tooltips, auto-scaling - Thicker lines (width 2) and stronger fill (30% opacity) - Dark theme CSS overrides for uPlot - ResizeObserver for responsive chart sizing History: - On page load, fetches /api/history and seeds ring buffers with server-side history (up to 1 hour of data) - Converts cumulative counters to rates client-side - Charts show data from before the page was opened Ring buffer: - Added pushRaw(timestampMs, value) for loading history - Increased default capacity to 500 points Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Line width 2.5 (was 1.5) - Fill opacity 40% (was 18%) - Grid lines brighter (#252d40) - Chart card background darker (#0c0f16) for contrast - Removed .u-over background override that was fighting uPlot Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each chart now has a minimum Y-axis range so small values don't fill the entire chart height. Auto-scales beyond the minimum when data exceeds it. Ranges: - Lines/sec: 0-1K - Input Bytes: 0-100KB - Errors/sec: 0-10 - Process CPU: 0-10% - Memory: 0-64MB - Batch Latency: 0-100ms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge conflict in main.rs: keep both our config_path/config_yaml params and master's CliError return type. Clippy fixes: - Remove unused imports (Instant, AtomicI32) - Add Default impl for StderrCapture Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The minified HTML triggers false positives (CSS class ".pn" → "should be on"). Exclude the build artifact from typos since it's machine-generated, not human-authored. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Requesting changes for one confirmed functional issue: the chart lifecycle currently destroys/recreates uPlot on each render, which causes unnecessary churn and state reset under the regular polling re-renders.
Note
🔒 Integrity filtering filtered 1 item
Integrity filtering activated and filtered the following item during workflow execution.
This happens when a tool call accesses a resource that does not meet the required integrity or secrecy level of the workflow.
- pr:#526 (
pull_request_read: has lower integrity than agent requires. The agent cannot read data with integrity below "approved".)
What is this? | From workflow: AI: PR Review
Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.
| const plotRef = useRef<uPlot | null>(null); | ||
| const roRef = useRef<ResizeObserver | null>(null); | ||
|
|
||
| useEffect(() => { |
There was a problem hiding this comment.
useEffect runs after every render here and its cleanup destroys the plot (plot.destroy()), so the chart is torn down and recreated on each parent re-render instead of being incrementally updated.
In this PR, App calls forceUpdate on each poll tick, which means this component re-renders continuously; that repeatedly rebuilds uPlot/ResizeObserver, resets chart interaction state, and adds avoidable CPU churn.
Please split this into:
- a mount/unmount effect (
useEffect(..., [])) that creates the plot + observer and only destroys on unmount - an update effect that only calls
setData/setScalewhenseriesdata changes
The committed dashboard.html is now a tiny placeholder (479 bytes) that tells devs to run `just dashboard`. CI builds the real one before every cargo step. - All CI jobs (lint, test, release-check, cross-compile, test-macos) now run `npm ci && npm run build` before cargo - Removed dashboard-check job (nothing to check freshness of) - Added `pn` to typos allow-list (CSS class name) - Typos exclude for built dashboard-dist kept as belt-and-suspenders Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI runs stricter clippy than local: - Use .cast::<libc::c_void>() instead of as *const/mut - Use match n.cmp(&0) instead of if/else chain Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…check Local devs run `just ci` (lint + test) without needing Node.js. The placeholder dashboard.html is enough for cargo to compile. CI workflows build the real dashboard before cargo steps. `just dashboard` is available when editing the frontend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clippy: use write!() instead of push_str(&format!()) in metric_history. CI dedup: extracted .github/actions/build-dashboard composite action. All 6 jobs that need the dashboard now use a single line: - uses: ./.github/actions/build-dashboard Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Master introduced a test module `remainder_tests` in pipeline.rs that uses Arc and ComponentStats without importing them. Added `use super::*` to bring them into scope. Also: deduplicated dashboard build steps in CI using a composite action at .github/actions/build-dashboard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The protobuf advisory is no longer in the dependency tree (dep was updated). cargo-deny and cargo-audit both error on unmatched ignores. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
cargo-deny treats path deps without a version field as wildcard dependencies. Added version = "0.1.0" to all internal crate path deps across the workspace. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The macOS CI test failures were caused by two issues:
1. http_get() connected immediately after server.start() without
waiting for the port to be ready. Fixed with retry loop (20
attempts, 50ms backoff = 1s total).
2. server.start() returns Result but tests assigned to _handle
without unwrapping — silently ignoring bind failures. Fixed
with .expect("server bind failed").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
free_port() releases the port before the server binds — running tests in parallel means another test can grab the same port, causing "Address already in use" on macOS CI. Added a static Mutex that serializes all diagnostics HTTP tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
dashboard/frontend project: Preact, TypeScript, Vitevite-plugin-singlefiledashboard.htmlwith a proper component architectureDashboard architecture
Build integration
crates/logfwd-io/src/dashboard.htmlis committed socargo buildworks without Node.jsjust dashboard: rebuilds from source (requires Node.js)just dashboard-check: CI verifies committed HTML matches sourcecd dashboard && npm run dev— hot reload, proxies API to :9090Test plan
cd dashboard && npm run buildproduces crates/logfwd-io/src/dashboard.htmlcargo build -p logfwdcompiles with the new dashboard embeddedjust dashboard-checkpasses (HTML matches source)npm run dev+ running logfwd → dashboard renders at localhost:5173🤖 Generated with Claude Code