ops-console

A real-time operations dashboard in Angular 22: zoneless change detection, signals, standalone components. 5,000 services stream status, metrics and alerts at 10,000 events a second, the screen holds 60 frames a second while you search and sort, and every feature works from the keyboard alone.

Live dashboards go wrong in two ways. Work done per event turns into dropped frames and a search box that lags. And accessibility breaks: rows move under the keyboard focus, a screen reader announces every update, nothing can be paused. This repository treats both as claims to measure, with frame timings from Chromium, axe audits, and Playwright tests that only press keys.

View on GitHub Measurements

Plays the seven stages below in order, about 45 seconds. Scroll or press Esc to stop.

Language
TypeScript, Angular 22, zoneless
Tests
60 passing: 24 Vitest, 8 node:test, 28 Playwright
Stream
5,000 services, 10,000 events a second, over server-sent events
Measured
Frames, input delay and data age, in Chromium, four stores, six conditions
Accessibility
No axe violations in four audits, in both themes
Dependencies
Angular only: no chart, grid or UI library
License
MIT
node perf/run.mjs --modes batched,naive --rates 10000 --seconds 15 --warmup 5 --check batched 10000/s frames p50 16.7 p95 16.7 ms, dropped 0.2%, long tasks 0 (0 ms), input p50 20.4 p95 38.9 ms, data age p95 24 ms, renders 60/s, received 10004/s

Real runs at 6aedde9, output verbatim. The perf check and the Playwright tests ran in the Playwright container CI uses, the unit tests on Node 22. The machine was busy with other work at the time, which the perf check's last line records.

1A stream you can replay

A Node server with no dependencies serves the built app and an SSE endpoint that sends one message per event, as most real event sources do. How the client copes with that is the point of the app.

  • The events are a pure function of a seed and the fleet size, so the tests, the perf runs and the demo all see the same stream.
  • Each event is a small array, and the server stamps its send time as the last element. That is how the app measures data age.
  • If the client can't keep up, the socket fills and the server waits for it to drain. Events are delayed, never skipped.
  • A reconnect asks for from=<count>, so the sequence continues instead of restarting.

The first tenth of a second at 200 events a second

What a million events are, seed 1, 5,000 services

The stream is curl against the repository's server, verbatim, without the blank line that ends each message; curl stopped after 0.12 s and 22 events. A metric is [0, id, cpu, latency, rps, sent]; [1, id, status, sent] is a status change. The mix was counted for this page by running the repository's generator for 1,000,000 events.

2Four ways to apply an event

Four stores implement one interface. The components, the ordering and the stream are shared, so a perf run compares exactly one thing: what a single event does on its way to the screen.

  • Batched, the default: newest values go into typed arrays, and once per animation frame they are applied and one signal is bumped.
  • Eager: the same store, applying each event and bumping its signals at once.
  • Naive: what a straightforward signal store does, a new 5,000-entry list per event, then set the signals.
  • Zone: the eager store in a zone.js build, where change detection runs after every event handler.

A unit test feeds the same 30,000 generated events to batched, eager and naive and requires the exact same end state. It caught one real bug: Float32 buffers had turned 43.7 into 43.70000076.

Renders a second at 10,000 events a second

From perf/results-desktop.json, the README's run: Angular's afterEveryRender count over 20 seconds, on a log scale. Eager writes its signals on every event, yet Angular's zoneless scheduler folds those writes into about 100 renders a second.

3Find where each one breaks

perf/run.mjs drives the production build in Chromium at 1400 by 900. Each condition gets a fresh page, 5 seconds of warm-up and 20 seconds of measurement, while a scripted user types into the search box every 500 ms. The page records its own timings.

  • Dropped frames: the frames missed between requestAnimationFrame callbacks.
  • Input delay: from each keydown to just after the next frame is drawn.
  • Data age: from the server sending an event to the end of the frame that first shows it.
  • "4x slower CPU" is Chrome's CPU throttling, the setting Lighthouse uses for a mid-range phone.

The two collapses are rendering per event and copying per event. Batching per frame isn't what saves the dashboard; eager is within noise of it. What batching buys is a cost per frame that stops growing with the event rate.

Every store, every condition

The README's run is perf/results-desktop.json and results-throttled.json as committed, measured with the machine nearly idle. The busy re-run is the same script on the same machine for this page, at three of the six conditions, while other jobs kept its load average between 13 and 22. Input delay is the 95th percentile, on a log scale.

4Only the rows in view exist

The fleet is a real <table>, virtualised by hand. Only the rows in view plus six either side are in the DOM, and hidden spacer rows hold the scroll height.

  • aria-rowcount and aria-rowindex tell assistive technology where each row sits in the whole list.
  • Each row's name is a button, and only one is in the tab order. The arrow keys, Page Up, Page Down, Home and End move a roving tabindex, scrolling the row into view first.
  • The visible values re-render from one signal read, once per frame in the batched store, whatever the event rate.
  • Charts are a few lines of canvas, each with a text alternative and its numbers also shown as text.

The running app at 10,000 events a second

The ops console in the light theme: summary tiles with 4,595 OK, 184 warning, 191 critical and 30 offline services, 10,043 events per second and a data age of 30 ms; the fleet table sorted by severity; and a details panel for cache-eu-central-0664 with CPU and latency sparklines. The ops console in the dark theme: 4,879 OK, 58 warning, 56 critical and 7 offline services, 10,022 events per second, data age 19 ms, the fleet table and the alert feed.
Screenshots of the production build in Chrome at 1400 by 900, cropped, one per theme: the page shows the one that matches yours. The counts were read from the same page: table rows in the DOM, the table's aria-rowcount, and every element in the document. The DOM node range is from the ten-minute soak below.

RenderedAbove this line it is the machine's problem: how fast events reach the screen. Below it, a person is using the screen, and the rules change: nothing moves under their focus, nothing talks over them, and everything can be stopped.

5Rows hold still under the keyboard

The table re-sorts once a second. While focus is anywhere in it, the order holds, so a row never moves out from under the keyboard. The values keep updating, and a note under the table says so.

  • A new filter still applies at once, even while the order is held.
  • The alert list works the same way: while focus is in it, new alerts wait behind a "Show N new alerts" button.
  • When a pressed button disappears, such as Acknowledge becoming Acknowledged, focus moves to its replacement. Closing details returns focus to whatever opened them.

Scrolling the focused row out of view with a mouse removes it from the DOM, and focus goes back to the page. Keyboard navigation always keeps it in view. The README lists this.

The top ten rows, three seconds apart

Focus outside the table

      Keyboard in the table

          Captured from the running app at 10,000 events a second. The keyboard got into the table the way a user would: Tab to the skip link, Enter, then two Down arrows. The notes under each pair are the table's own text, and the row values are the first four rows as rendered, three seconds apart.

          6One sentence every ten seconds

          Two polite live regions. One speaks the result of something the user did, at once. The other sums up new alerts at most once every ten seconds, so a burst of fifty alerts is one sentence, not fifty.

          • The level is a choice: critical alerts only, the default, all alerts, or off.
          • Status is never shown by colour alone. A shape and the word go with it, in the table and the tiles.
          • A Playwright test watches the region for 21 seconds while dozens of alerts arrive, and allows at most three announcements.

          Nobody has used it with NVDA, JAWS or VoiceOver yet. The roles, names, live regions and focus order are tested automatically; the README says a screen reader is where the live regions and the virtual table would most likely find something.

          31 seconds of the alerts live region, during an incident

            Captured from the running app with the default level, critical alerts only, by observing the live region the way the test does. An incident in eu-central was under way, which the generator raises every so often. The sentences are the region's text, verbatim.

            7Checked from the keyboard alone

            Every feature has a Playwright test that never clicks and never calls focus(): skip link, search, status filters, region and sort, row navigation, details, silence, acknowledge, pause, theme and the shortcuts dialog.

            • axe-core finds no WCAG 2.1 A or AA violations in four audits, in both themes: streaming, details open with an alert acknowledged and updates paused, the shortcuts dialog, and a search with no results.
            • p pauses updates (WCAG 2.2.2), and the single-key shortcuts can be turned off (2.1.4).
            • A node test checks every colour-token pair in both themes. On the first CI run it caught a selected row's name at 4.34:1 in the dark theme, which hadn't shown locally.
            • With reduced motion, a test requires that nothing on the page has a transition or an animation.

            The 28 browser tests, and the shortcuts dialog

              The keyboard shortcuts dialog: slash to search by name, p to pause or resume updates, question mark for this list, arrows, Page Up and Page Down, Home and End in the table, Enter to open a row's details, Escape to close, and a checked box for single-key shortcuts.
              The list is npx playwright test in the Playwright 1.64.1 container with two workers, as in CI: 28 passed in 36.3 s, durations verbatim. The dialog is a screenshot after pressing ?. The terminal is what the summary tiles read before and two seconds after pressing p, captured from the page.

              Measured, not assumed

              The README's numbers come from one machine, an i5-12400F with 12 threads, nearly idle, in headless Chromium 156 over loopback. The same machine re-ran the soak and part of the comparison for this page while it was busy with other work, which is a harder test than the README's.

              Ten minutes at 10,000 a second

              The batched store with details open; every 30 seconds a forced garbage collection, then the heap and the DOM node count.

              6.3 million events5.06 to 5.20 MB1,829 to 1,889 nodes

              After the two-minute warm-up the heap stayed within 0.14 MB and ended at 5.15 MB, so --check passed. Every buffer has a fixed size: two minutes of history per service, the newest 500 alerts, a window of rows.

              On a busy machine

              Dropped frames in the re-run, load average 13 to 22.

              Batched, 40k/s0.0%
              Naive, 40k/s98.4%
              Batched, 10k/s, 4x32.9%
              Eager, 10k/s, 4x10.6%

              At full speed the pattern held. Throttled on a loaded CPU, batched and eager dropped frames too, against 0.1% and 0.5% in the README's quiet run.

              Four CI jobs

              Run 38671231509 on 6aedde9, all passed. Durations from GitHub's API.

              CI's perf job checks only the batched store, against thresholds well below what the README's machine shows: frame p95 50 ms, dropped 25%, input p95 150 ms.

              60 tests: unit, server and browser

              Counted the way CI runs them: npm test runs ng test (Vitest, 24) and node --test on the server and contrast tests (8), and npx playwright test runs 28 against the production build. All passed here.

                What they pin

                • The stores: any number of events folded into one update per frame, status counts, alerts raised, acknowledged, silenced and resolved, pause and catch-up, at most 500 alerts, and three stores ending in the same state on 30,000 events.
                • The view and table: sort and filter, re-ordering on the tick and not on every change, the held order, a rendered window of rows with each one's position, and exactly one row in the tab order.
                • The server: a stream that is a pure function of its seed, the rate asked for and a resume where told, and bad parameters and paths outside the app refused.
                • Contrast: every colour pair in both themes, and the old selected-row colour that would have failed.
                • The browser: keyboard-only tests for every feature, axe in both themes, batched announcements, reduced motion, and each store streaming without console errors.

                CI also runs the perf check at 10,000 events a second and the ten-minute soak on every push.

                Deliberately missing

                A screen reader trial
                Roles, names, live regions and focus order are tested automatically, but nobody has used it with NVDA, JAWS or VoiceOver.
                Other browsers and devices
                One machine, headless Chromium, loopback. Firefox, Safari, real networks and real phones weren't measured.
                Real data
                The rates, alert mix and incidents are a model, and acknowledgements and silences live only in the page.
                The worst store
                The naive store is one common pattern. One that also re-sorted per event would do worse, and a per-entity signal store would do better.
                Being a product
                No authentication, persistence or alert routing. It is a front end and a test bench.

                Run it

                git clone https://github.com/ahmed-hashim-pro/ops-console.git
                cd ops-console
                npm ci
                npm start          # http://127.0.0.1:8080
                npm test           # 24 Vitest + 8 node:test
                
                # Or with Docker only:
                make run
                make e2e           # 28 Playwright tests
                make perf-check    # batched at 10,000/s

                Node 22 or newer. The query string picks what you see: /?rate=40000, /?mode=naive, /zone/ for the zone.js build, and /?demo to show each key pressed.