Skip to content

feat: Preact + TypeScript diagnostics dashboard#526

Merged
strawgate merged 24 commits into
masterfrom
feat/dashboard-v5
Apr 1, 2026
Merged

feat: Preact + TypeScript diagnostics dashboard#526
strawgate merged 24 commits into
masterfrom
feat/dashboard-v5

Conversation

@strawgate
Copy link
Copy Markdown
Owner

Summary

  • New dashboard/ frontend project: Preact, TypeScript, Vite
  • Builds to a single 33KB inlined HTML file via vite-plugin-singlefile
  • Replaces the 138-line prototype dashboard.html with a proper component architecture
  • Integrated into justfile, CI, and release workflows

Dashboard architecture

dashboard/src/
  app.tsx           — top-level state + polling
  api.ts            — typed fetch client
  types.ts          — TypeScript interfaces for /api/* responses
  style.css         — dark theme, WCAG AA contrast
  components/
    StatusBar       — health pill + version
    SystemRow       — RSS, heap, batch count
    MetricBadges    — system-level metrics
    ChartGrid       — 6 Canvas time-series charts
    Chart           — single chart with Y-axis, time markers
    PipelineView    — flow diagram + click-to-expand inspector
    ConfigView      — YAML syntax highlighting
  lib/
    format.ts       — fmt, fmtBytes, fmtDuration
    ring.ts         — ring buffer for time series
    rates.ts        — EMA-smoothed rate tracker

Build integration

  • Committed HTML: crates/logfwd-io/src/dashboard.html is committed so cargo build works without Node.js
  • just dashboard: rebuilds from source (requires Node.js)
  • just dashboard-check: CI verifies committed HTML matches source
  • Release workflow: builds dashboard before cargo in each matrix job
  • Dev mode: cd dashboard && npm run dev — hot reload, proxies API to :9090

Test plan

  • cd dashboard && npm run build produces crates/logfwd-io/src/dashboard.html
  • cargo build -p logfwd compiles with the new dashboard embedded
  • just dashboard-check passes (HTML matches source)
  • CI "Dashboard freshness" job passes
  • Dev server: npm run dev + running logfwd → dashboard renders at localhost:5173

🤖 Generated with Claude Code

strawgate and others added 2 commits March 31, 2026 19:16
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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 1, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

A new Preact-based dashboard frontend is introduced in dashboard/, featuring components for visualizing pipelines, metrics time-series, logs, and configuration. The built dashboard is embedded as crates/logfwd-io/src/dashboard.html. Supporting changes add two new diagnostics API endpoints (/api/config, /api/logs), a stderr capture module for log streaming, and platform-aware process metrics collection. CI workflows are updated to build and validate the dashboard. The main application now passes config path and YAML content to the diagnostics server.

Possibly related PRs


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 2dc75dc and 34c1860.

⛔ Files ignored due to path filters (3)
  • dashboard/package-lock.json is excluded by !**/package-lock.json
  • dashboard/public/favicon.svg is excluded by !**/*.svg
  • dashboard/public/icons.svg is excluded by !**/*.svg
📒 Files selected for processing (27)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • .gitignore
  • crates/logfwd-io/src/dashboard.html
  • dashboard/.gitignore
  • dashboard/index.html
  • dashboard/package.json
  • dashboard/src/api.ts
  • dashboard/src/app.tsx
  • dashboard/src/components/Chart.tsx
  • dashboard/src/components/ChartGrid.tsx
  • dashboard/src/components/ConfigView.tsx
  • dashboard/src/components/MetricBadges.tsx
  • dashboard/src/components/PipelineView.tsx
  • dashboard/src/components/StatusBar.tsx
  • dashboard/src/components/SystemRow.tsx
  • dashboard/src/lib/format.ts
  • dashboard/src/lib/rates.ts
  • dashboard/src/lib/ring.ts
  • dashboard/src/main.tsx
  • dashboard/src/style.css
  • dashboard/src/types.ts
  • dashboard/tsconfig.app.json
  • dashboard/tsconfig.json
  • dashboard/tsconfig.node.json
  • dashboard/vite.config.ts
  • justfile

Comment thread .github/workflows/release.yml Outdated
Comment on lines +46 to +48
- name: Build dashboard
run: cd dashboard && npm ci && npm run build

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Comment thread crates/logfwd-io/src/dashboard.html Outdated
<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>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment thread dashboard/index.html
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

/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.

Suggested change
<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).

Comment thread dashboard/package.json
"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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Comment thread dashboard/src/api.ts
Comment on lines +3 to +10
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -path "*/dashboard/src/api.ts" -o -path "*/api.ts" | head -20

Repository: strawgate/memagent

Length of output: 84


🏁 Script executed:

git ls-files | grep -E "(api|dashboard)" | head -30

Repository: strawgate/memagent

Length of output: 941


🏁 Script executed:

fd "api.ts" --type f

Repository: strawgate/memagent

Length of output: 82


🏁 Script executed:

cat -n dashboard/src/api.ts

Repository: strawgate/memagent

Length of output: 648


🏁 Script executed:

rg "get<" dashboard/src --type ts -A 3 -B 1

Repository: strawgate/memagent

Length of output: 589


🏁 Script executed:

rg "polling\|interval\|subscribe\|refresh" dashboard/src --type ts -B 2 -A 2

Repository: strawgate/memagent

Length of output: 44


🏁 Script executed:

rg "api\.(pipelines|stats|config)" dashboard/src --type ts -B 2 -A 2

Repository: strawgate/memagent

Length of output: 645


🏁 Script executed:

rg "setInterval|useEffect|setImmediate" dashboard/src --type ts -B 1 -A 3

Repository: strawgate/memagent

Length of output: 1757


🏁 Script executed:

cat -n dashboard/src/app.tsx | head -100

Repository: strawgate/memagent

Length of output: 4506


🏁 Script executed:

cat -n dashboard/src/app.tsx | tail -30

Repository: strawgate/memagent

Length of output: 989


🏁 Script executed:

rg "AbortController|timeout|fetch.*signal" dashboard/src --type ts

Repository: 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.

Suggested change
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.

Comment on lines +16 to +22
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";
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +31 to +33
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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment thread dashboard/src/style.css
--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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
--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.

Comment thread dashboard/src/types.ts Outdated
Comment thread justfile
Comment on lines +49 to +50
dashboard:
cd dashboard && npm install --prefer-offline && npm run build
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Suggested change
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>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
crates/logfwd-io/src/dashboard.html (1)

8-8: ⚠️ Potential issue | 🟠 Major

Use real buttons for the pipeline cards.

The shipped bundle still renders the .pn cards as clickable divs with onClick only. Keyboard users cannot open the inspectors. Fix dashboard/src/components/PipelineView.tsx to 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 | 🟠 Major

Serialize the polling loop.

setInterval does not wait for the previous async poll() to finish. Slow responses will overlap and race on the shared rates tracker and seriesRef.current mutations.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 34c1860 and db9b0f5.

📒 Files selected for processing (4)
  • crates/logfwd-io/src/dashboard.html
  • crates/logfwd-io/src/diagnostics.rs
  • crates/logfwd/src/main.rs
  • dashboard/src/app.tsx

Comment thread crates/logfwd-io/src/diagnostics.rs
Comment thread dashboard/src/app.tsx Outdated
Comment on lines +99 to +106
// 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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
// 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":"#);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/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)

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (7)
dashboard/src/components/PipelineView.tsx (3)

67-84: ⚠️ Potential issue | 🟡 Minor

Missing key prop on Fragment for outputs.

Same issue as inputs—add key to 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 | 🟠 Major

Calling rates.rate() and tick() during render corrupts rate calculations.

Both rate() and tick() 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 | 🟡 Minor

Missing key prop on Fragment.

React/Preact requires a key on 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 | 🟠 Major

Polling loop can overlap if poll() takes >2s.

setInterval doesn't await the async poll(). Overlapping calls corrupt RateTracker state. 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 | 🟠 Major

Batch 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 | 🟠 Major

Generated 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 | 🔴 Critical

Raw config exposed via /api/config leaks credentials.

serve_config returns config_yaml verbatim—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

📥 Commits

Reviewing files that changed from the base of the PR and between db9b0f5 and 26c7e12.

📒 Files selected for processing (7)
  • crates/logfwd-io/src/dashboard.html
  • crates/logfwd-io/src/diagnostics.rs
  • crates/logfwd-io/src/lib.rs
  • crates/logfwd-io/src/stderr_capture.rs
  • dashboard/src/app.tsx
  • dashboard/src/components/LogViewer.tsx
  • dashboard/src/components/PipelineView.tsx

Comment on lines +146 to +151
std::thread::Builder::new()
.name("stderr-capture".into())
.spawn(move || {
reader_loop(read_fd, orig, &state);
})
.ok();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (6)
dashboard/src/components/LogViewer.tsx (2)

41-52: ⚠️ Potential issue | 🟠 Major

Use a real button for the log opener.

The clickable div at 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 | 🟠 Major

Serialize the /api/logs poller.

This setInterval can 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 | 🟠 Major

Batch 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 | 🟠 Major

Serialize the main poll loop.

setInterval can start a new poll() before the previous one finishes. That lets older responses clobber newer state and makes rates.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 | 🟠 Major

Quote the font-family names in --sans.

Line 19 still fails value-keyword-case for BlinkMacSystemFont and Roboto. Quote them here, then regenerate crates/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 | 🟠 Major

The shipped bundle still uses clickable pipeline divs.

This file is served as-is, and the compiled .pn nodes are still div + onClick, so keyboard users cannot open inspectors. Fix dashboard/src/components/PipelineView.tsx in 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

📥 Commits

Reviewing files that changed from the base of the PR and between 26c7e12 and 1aa51d1.

📒 Files selected for processing (4)
  • crates/logfwd-io/src/dashboard.html
  • dashboard/src/app.tsx
  • dashboard/src/components/LogViewer.tsx
  • dashboard/src/style.css

Comment thread dashboard/src/app.tsx
Comment on lines +28 to +36
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" },
];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +35 to +37
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs.length]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (7)
dashboard/src/lib/format.ts (2)

31-33: ⚠️ Potential issue | 🟡 Minor

Use 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 | 🟡 Minor

Do not treat 0 bytes as missing.

On Line 17, b === 0 returning "-" 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 | 🟠 Major

Render-time rate() call mutates tracker state.

RateTracker.rate() updates internal maps on every call. Invoking it during render (via compRate) 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 | 🟡 Minor

Missing key on 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 | 🟡 Minor

Auto-scroll breaks once log buffer caps at 1000 lines.

The server caps stderr at 1000 lines. Once full, logs.length stays 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 | 🟠 Major

Polling race condition: overlapping requests can cause state jumps.

setInterval(poll, 2000) fires every 2s regardless of whether the previous poll() 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 | 🟡 Minor

Quote font family names to satisfy stylelint.

Stylelint's value-keyword-case rule flags unquoted BlinkMacSystemFont and Roboto.

🔧 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

📥 Commits

Reviewing files that changed from the base of the PR and between 1aa51d1 and b911300.

📒 Files selected for processing (7)
  • crates/logfwd-io/src/dashboard.html
  • dashboard/src/components/LogViewer.tsx
  • dashboard/src/components/PipelineView.tsx
  • dashboard/src/lib/format.ts
  • dashboard/src/lib/rates.ts
  • dashboard/src/style.css
  • dashboard/src/types.ts

Comment on lines +37 to +46
<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>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment thread dashboard/src/style.css
Comment on lines +232 to +248
.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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Comment thread dashboard/src/types.ts
Comment on lines +19 to +29
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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 -60

Repository: 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:

  • batches as a nested object with total, avg_rows, flush_by_size, flush_by_timeout, dropped_batches_total, scan_errors_total — not a single number
  • Timing data nested in stage_seconds object with scan, transform, output — not flat scan_sec, transform_sec, output_sec fields
  • No backpressure_stalls field 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>
@strawgate strawgate force-pushed the feat/dashboard-v5 branch from 073f6f4 to 68d7b2b Compare April 1, 2026 02:12
strawgate and others added 6 commits March 31, 2026 21:22
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>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/setScale when series data changes

strawgate and others added 6 commits March 31, 2026 22:07
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>
strawgate and others added 5 commits March 31, 2026 22:57
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant