Skip to content

Latest commit

Β 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

USB Dashboard

Electron 40 Node.js >=18 Platform: Windows License: PolyForm Noncommercial 1.0.0 Status: personal project

A retro-modern kiosk HUD that turns a spare secondary USB display into a live dashboard for your machine, your workflow and your projects.


A system dashboard built with Electron that runs full-screen, kiosk-style, on a secondary 960x640 USB display and shows an always-on HUD with CPU, RAM, GPU, overall system temperature, Claude/Anthropic usage, Linear tasks, coding time (WakaTime) and project health (Sentry), spread across 4 pages that rotate on their own. The theme switches to a dark palette outside working hours.

The window opens frameless, always-on-top, with a modernized retro look (Macintosh-classic inspired, in color) and large type, since the screen is small and read from a distance. The full visual system is documented in DESIGN.md.

Main page: CPU, RAM, GPU, temperature and Claude usage WakaTime page: coding time today and top projects


Table of contents


Requirements

  • Windows 11 (the app works on other systems, but display detection and GPU data were tuned on this machine)
  • Node.js 18+ (tested with v24)
  • LibreHardwareMonitor, running with its Remote Web Server on. This is a real dependency of this setup, not an optional extra like the ones below: it's the single source for CPU, RAM, GPU/VRAM and temperature (one HTTP fetch per cycle covers all four). Without it, CPU/RAM/GPU quietly fall back to WMI/systeminformation/ nvidia-smi (CPU reads noticeably higher than Task Manager on that path), and the temperature widget shows "unavailable" (nothing to fall back to). See "LibreHardwareMonitor widgets" for install/config steps and how it's kept running across reboots on this machine.
  • Optional: nvidia-smi on the PATH, as the GPU widget's fallback if LHM isn't running. Without either, the widget shows "unavailable".
  • Optional: a Linear API key, for the tasks widget (see "Linear widget" below). Without it the widget shows "unavailable" with instructions on how to configure it.
  • Optional: a WakaTime API key in ~/.wakatime.cfg (the same file editor extensions / the Claude Code plugin already use). Without it, "unavailable".
  • Optional: the official sentry CLI installed, authenticated and on the PATH, for the project health widget. Without it, "unavailable" -- the app never manages any Sentry key itself, it only calls the CLI that's already logged in.

Running it

Double-click start-dashboard.bat (installs dependencies on first run if needed, then starts the app). Or, from a terminal:

npm install
npm start

Available scripts:

Script What it does
npm start Kiosk mode: opens frameless on the USB screen (or a normal window if not found)
npm run start:dev Same as start, but opens DevTools right away
npm run start:windowed Forces a normal window on the primary monitor, for developing without the screen
npm run dist Packages a portable .exe with electron-builder (see Packaging)

Keyboard shortcuts

Since the window has no frame, shortcuts are the only way to control the app:

  • Esc or Ctrl+Q -- quits the app
  • Ctrl+Shift+I -- opens/closes DevTools
  • F5 or Ctrl+R -- reloads the UI
  • Space -- pauses/resumes automatic page rotation
  • Left arrow / Right arrow -- navigates pages manually (resets that page's timer, so it won't jump on its own right after)

Project structure

src/
  main/                     main process (the only place with Node/OS access)
    main.js                 kiosk window, IPC, shortcuts, repositioning, crash recovery
    config.js                screen bounds, paths and collection intervals
    channels.js               IPC channel names (source of truth)
    display.js                 locates the USB screen among the monitors
    logger.js                  console + file logging (with rotation)
    metrics-hub.js              runs the collectors and pushes data to the renderer
    collectors/
      index.js                collector registry
      hardware.js                CPU + RAM + GPU/VRAM + temperature, one LibreHardwareMonitor
                                    fetch per cycle (WMI/systeminformation/nvidia-smi as
                                    per-section fallback -- see "Temperature widget" below)
      lhm-client.js                shared LibreHardwareMonitor HTTP client
      claude-usage.js             reads the Claude usage cache
      linear.js                    tasks assigned in Linear (Linear API)
      wakatime.js                   coding time today (WakaTime API)
      sentry.js                      issues/alerts/crons/feedback (`sentry` CLI)
  preload/
    preload.js                contextBridge: exposes only getSnapshot/onUpdate
  renderer/
    index.html                 page structure, page container and script order
    styles.css                   modernized retro HUD theme (see DESIGN.md)
    app.js                        widget/page registry, grid, data distribution
    widgets/                      one file per widget

Security

The renderer runs with contextIsolation: true, nodeIntegration: false and sandbox: true. It has no require, fs or process: it only receives ready-made JSON objects through the IPC channel exposed in the preload. All system reads happen in the main process.


How the display is detected

The monitor's index in Windows' array changes between boots (depends on which display comes up first), so the app never uses a fixed index. Instead, src/main/display.js looks for the monitor by comparing it against the target defined in src/main/config.js:

const TARGET_DISPLAY = { x: 528, y: 1440, width: 960, height: 640 };

The search happens in three tiers, from strongest to weakest match:

  1. Position + resolution match (with 40px tolerance on position).
  2. Only the resolution matches, i.e. the screen was moved within Windows' monitor arrangement.
  3. Nothing matches: the app opens in a normal window, centered on the primary monitor, with a frame and resizable. It won't break if the USB screen is off.

If you swap displays, just adjust TARGET_DISPLAY. To find the new bounds, run npm run start:windowed: when the target isn't found, the app prints the full list of detected monitors to the console.

The app also listens for display-added, display-removed and display-metrics-changed: if the USB screen is unplugged and reconnected, the window moves back to the right spot on its own.


Where the Claude usage data comes from

The app does not authenticate with Anthropic and does not make any network calls for this. It just reads, every 30 seconds, the local cache kept by another tool (oh-my-claudecode):

C:\Users\<user>\.claude\claude-usage-cpe.cache.json

Expected format:

{
  "fetchedAt": "2026-08-15T22:23:21+00:00",
  "windows": [
    { "label": "5-hour window", "percent": 3, "resets_at": "2026-08-16T02:59:59+00:00" },
    { "label": "Weekly", "percent": 1, "resets_at": "2026-08-22T16:59:59+00:00" }
  ]
}

Parsing is defensive:

  • windows are identified by their label field (5-hour / week), never by array position;
  • a missing file, truncated JSON (the cache can be read mid-rewrite) or missing fields all fall back to -- on screen, without crashing the app;
  • the card header shows how long ago the cache was updated, and the whole card dims if data stops arriving.

The path lives in CLAUDE_USAGE_CACHE_PATH, in src/main/config.js.


Pages and rotation

The screen is small (960x640) and the main page's grid (CPU, RAM, GPU, temperature, Claude) already uses the space comfortably -- instead of cramming more widgets in there, new widgets can declare page: 'some-id' (see Linear below) and get their own page, which rotates automatically with the others.

How it works:

  • Each distinct page becomes its own grid, created the first time a widget declares that page. Widgets without a page fall into the 'main' page.
  • Each page stays visible for its own duration, not one fixed interval for all of them: main (5 widgets, more to read) stays for 12s; tasks (Linear), coding (WakaTime) and errors (Sentry) stay 4-6s each. The values live in PAGE_DURATIONS_MS, in src/renderer/app.js -- a page with no entry there falls back to DEFAULT_PAGE_DURATION_MS.
  • All widgets keep receiving updates all the time, even on a page that isn't visible -- only the display is hidden (display: none). When rotating back to a page, its data is already up to date.
  • The dots in the top bar (#page-dots) show how many pages exist and which one is active. With a single page, the dots stay hidden and nothing runs (no reason to rotate between one thing, and navigation shortcuts wouldn't make sense either).
  • Space pauses/resumes automatic rotation (a pause icon appears in the top bar); Left arrow/Right arrow navigate manually and reset the timer of the page you moved to.

Linear widget (setup)

The "LINEAR" widget shows how many issues are assigned to you in Linear (and how many are "in progress" vs. stuck in backlog/todo). It's the only collector that makes a real network call -- every other one only reads local sensors or a file on disk.

To enable it, pick one of three options (the key is never hardcoded or committed -- .env and config.local.json are both in .gitignore):

Option A -- .env file (simplest, recommended):

  1. Copy .env.example to .env (at the project root).
  2. Fill in LINEAR_API_KEY with a Personal API Key generated in Linear -> Settings -> Security & access -> API.

The app loads .env on its own at startup (via dotenv, see main.js).

Option B -- environment variable (handy for running via start-dashboard.bat without depending on a file):

setx LINEAR_API_KEY "lin_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

(setx writes it permanently to the Windows user; close and reopen the terminal/shortcut afterwards for it to pick up the new variable. Takes priority over .env.)

Option C -- local file:

  1. Copy config.local.example.json to config.local.json (at the project root).
  2. Fill in linearApiKey with a Personal API Key generated in Linear -> Settings -> Security & access -> API.

Without any of the three, the card does not show "unavailable": it shows sample data (mocked, marked "sample data" in the card header), so you can see the widget working (layout, color, alert level) before configuring a real key. Only a real error (a configured but invalid key, a network failure) falls back to "unavailable" with the reason.


WakaTime widget (setup)

Shows coding time today (large highlight) plus the most-used languages and projects for the day. It reads the API key from WakaTime's own default config file:

~/.wakatime.cfg

The same file the editor extension and the claude-code-wakatime plugin (if installed) already use -- if you already have WakaTime configured on this machine for any other tool, this widget works with no extra steps. Without the file/key, "unavailable" with the expected path in the reason.

The collector polls every 2 minutes (INTERVALS.wakatime).


Sentry widget (setup)

Shows 4 numbers (open issues / configured alerts / cron monitors / user feedback) plus the project's most recent unresolved issues.

Unlike Linear and WakaTime, no API key is managed by the app at all. The collector spawns the official sentry CLI (the same way the GPU widget spawns nvidia-smi) -- it already handles its own OAuth authentication. For this widget to work you need:

  1. The sentry CLI installed and on the PATH.
  2. Authenticated (sentry auth status to check; sentry auth login if needed).

Org and project have a fixed default (bidchex / bidchex-backend, in src/main/config.js), overridable in config.local.json:

{ "sentryOrg": "your-org", "sentryProject": "your-project" }

Without the CLI or auth, "unavailable" with the reason. Alerts/crons/feedback are "bonus": if one specific endpoint fails, only that number turns into --, the rest of the card keeps working (same pattern as CPU temperature).

The collector polls every 2 minutes (INTERVALS.sentry) -- the heaviest of the collectors (spawns a CLI up to 4 times per cycle), but still negligible on a multi-minute interval.


LibreHardwareMonitor widgets (setup)

LibreHardwareMonitor is the single source for four widgets -- CPU, RAM, GPU/VRAM and TEMPERATURA -- all read from one HTTP fetch per cycle to LHM's built-in local web server (fetchSensorTree() in lhm-client.js, shared by the hardware.js collector). Nothing here is optional-feeling by accident: Windows doesn't expose motherboard/VRM/SSD temperatures at all without third-party software, and the usual native sources for CPU/RAM/GPU turned out to be worse than LHM on this machine:

  • CPU usage: WMI's Win32_Processor.LoadPercentage (the fallback) reads noticeably higher than Task Manager on CPUs with boost clocks (Turbo Boost/Precision Boost) -- LHM reads the CPU vendor-specific counters (the same ones Ryzen Master/HWiNFO use), which tracks Task Manager correctly.
  • RAM: systeminformation (the fallback) is accurate too on this machine, LHM is just the same data point already sitting in the one fetch this collector makes anyway.
  • GPU/VRAM: nvidia-smi (the fallback) is accurate too, same reasoning as RAM -- one fetch instead of a second process spawn every cycle.
  • Temperature: no fallback exists -- Windows has nothing native for this.

LHM's own WMI provider has been broken since 0.9.5 (issue #2143), which is why the collector talks to the HTTP server instead.

To enable it:

  1. Install LibreHardwareMonitor (winget install LibreHardwareMonitor.LibreHardwareMonitor) and run it as Administrator -- most sensors need elevated access on Windows.
  2. In the app: Options -> Remote Web Server -> Run. This serves a JSON sensor tree at http://localhost:8085/data.json. Saved to LHM's own config on change, so it's still on the next time LHM starts.
  3. Options -> Run On Windows Startup, so it's already running (elevated, Remote Web Server on) whenever the dashboard boots -- see below.

Without it running: CPU falls back to WMI, then to a ticks-based calculation if even WMI fails; RAM falls back to systeminformation; GPU/VRAM falls back to nvidia-smi. None of those three ever show "unavailable" for this reason alone, just a less accurate (CPU) or slightly slower-to-fetch (RAM/GPU) number. The temperature widget is the exception -- it shows "unavailable", since there's no fallback for it.

The sensor/counter ids (LHM.CPU_LOAD_SENSOR_ID, LHM.RAM_USED_SENSOR_ID, LHM.GPU_LOAD_SENSOR_ID, LHM.TEMPERATURE_SENSORS, etc., all in src/main/config.js) are hardcoded to what this specific machine's hardware exposes (e.g. /amdcpu/0/load/0, /ram/data/0, /gpu-nvidia/0/load/0). On a different CPU/GPU/motherboard those ids change -- run curl http://localhost:8085/data.json and adjust them.

The collector polls every 2 seconds (INTERVALS.hardware) -- one local HTTP call per cycle; the fallback paths are the only ones that spawn a process.

Keeping LibreHardwareMonitor running across reboots

Checking Options -> Run On Windows Startup inside LHM (step 3 above) doesn't add a Startup-folder shortcut -- it registers its own Scheduled Task (LibreHardwareMonitor, visible in taskschd.msc), triggered at logon, RunLevel: Highest (elevated, needed for sensor access), no manual Task Scheduler setup required. This is what actually keeps CPU/temperature readings accurate from the very first boot, before you'd have a chance to open the app by hand.

To confirm it's set up (PowerShell):

Get-ScheduledTask -TaskName LibreHardwareMonitor | Select-Object State

If it's missing or disabled, open LHM once and re-check Options -> Run On Windows Startup.


Night theme

Outside the hours set in NIGHT_START_HOUR/NIGHT_END_HOUR (default: 9pm-7am), in src/renderer/app.js, <body> gets the theme-night class, which only swaps the 3 base color variables (--cream/--paper/--ink) in styles.css -- the rest of the theme (per-widget accent colors, layout) uses those variables by reference, so the whole theme re-tints itself. Checked every minute.

The Linear collector polls the API every 2 minutes (INTERVALS.linear in src/main/config.js) -- task data doesn't change second to second, and this avoids hitting the Linear API unnecessarily.


Logs

The terminal still gets the same logs as always, but now they also go to a file, because the screen runs for days with nobody watching the terminal:

%APPDATA%\usb-dashboard\logs\app.log

Simple rotation: once that file passes ~2MB, it becomes app.log.1 and a fresh app.log starts. See src/main/logger.js.


Long-running robustness (watchdog.js)

The screen runs for days unattended, so on top of the file log there's src/main/watchdog.js, covering the scenarios uncaughtException and Task Scheduler alone don't:

  • Native crash of the main process (a segfault, not a JS error): the Electron crashReporter is enabled with uploadToServer: false -- the minidumps stay on disk only, in app.getPath('crashDumps') (%APPDATA%\usb-dashboard\Crashpad on Windows). On the next boot, if there is a dump from the last 24h, app.log gets a warning pointing at the folder.
  • Process hanging without exiting (deadlock, infinite loop): a heartbeat is written every 30s to %APPDATA%\usb-dashboard\watchdog-state.json. On the next boot, if the file shows the previous session didn't exit cleanly, app.log records how long ago the heartbeat stopped.
  • GPU process crashing (would leave the screen black with no log at all): the app listens for child-process-gone (an event render-process-gone doesn't cover) and reloads the window when the type is GPU.
  • Fast crash loop: if uncaughtException/unhandledRejection fires again within 30s of a previous abnormal exit, the crashStreak counter (persisted in the same watchdog-state.json) goes up. After 3 fast crashes in a row, auto-relaunch gives up (avoids an infinite CPU-burning loop) and just logs the reason -- Task Scheduler still covers the next logon. After a few minutes standing without falling over, the counter resets.
  • Slow memory leak: every 5 minutes, app.getAppMetrics() sums the memory of every process (main + renderer + GPU); above 500MB, it logs a warning.
  • Preventive daily relaunch (4am local time): app.relaunch() + app.exit() during a low-usage window, so a small leak doesn't accumulate over several days. Counts as a clean exit, doesn't affect crashStreak.

All the constants (memory threshold, relaunch hour, fast-crash window, etc.) live in WATCHDOG in src/main/config.js.


Packaging

npm run dist

Generates two formats in dist/, via electron-builder:

  • dist\win-unpacked\USB Dashboard.exe -- a folder with the app already unpacked. This is the target used for auto-start (see the section below): opens instantly, no self-extraction.
  • dist\usb-dashboard-portable.exe -- a single portable .exe (no installer, no admin), handy for copying to another machine. When run, it self-extracts to a new folder in %TEMP% on EVERY run -- that's why it's not used for auto-start: besides being slower, antivirus software may scan (and sometimes delay/block) a new executable in %TEMP% on every boot, in a way that doesn't happen with a .exe that always sits in the same place.

The build config lives in package.json -> "build". There's no code signing or auto-update configured -- it's a simple .exe for personal use on this machine.

config.local.json (secrets) after packaging

The build's files (package.json) only includes src/** and package.json -- config.local.json (Linear key, etc.) never goes into the app.asar on purpose, so a secret never leaks into a distributed .exe. Once packaged, the app looks for config.local.json next to the real .exe, no longer at the project root:

  • running via win-unpacked: put config.local.json inside dist\win-unpacked\ (same folder as USB Dashboard.exe).
  • running via usb-dashboard-portable.exe: put config.local.json next to the portable .exe (e.g. directly in dist\) -- electron-builder exports PORTABLE_EXECUTABLE_DIR pointing at that stable folder, even while the app runs from inside %TEMP%.

In dev (npm start), it's still read from the project root, as always.


Adding a new widget

Widgets are independent: each one has its own file and its own update cadence. Adding a new widget doesn't touch existing ones.

1. If the data doesn't exist yet, create a collector

src/main/collectors/whatsapp.js:

const whatsappCollector = {
  id: 'whatsapp',          // becomes the widget's "source"
  intervalMs: 10000,       // this collector's own cadence
  async collect() {
    // return a plain, serializable object
    return { available: true, unread: 3 };
  }
};

module.exports = { whatsappCollector };

Register it in src/main/collectors/index.js, adding it to the collectors array.

Contract rules:

  • collect() should not throw for expected errors; return { available: false, reason: '...' } so the UI shows "unavailable";
  • unexpected exceptions are caught by MetricsHub, which logs them and keeps the loop alive;
  • each collector runs its own chained cycle, so a slow collector doesn't delay the others or pile up.

2. Create the widget

src/renderer/widgets/whatsapp.js:

(function () {
  const { utils } = window.Dashboard;
  let value;

  window.Dashboard.registerWidget({
    id: 'whatsapp',
    title: 'WHATSAPP',
    source: 'whatsapp',    // collector id
    page: 'main',          // optional: groups into a page (default 'main'); see "Pages and rotation"
    span: 1,               // 1 = half width, 2 = full row
    accent: '#25d366',     // widget color
    staleAfterMs: 30000,   // optional

    // build the DOM once
    mount(body, ui) {
      value = ui.createBigValue('new');
      body.appendChild(value.el);
    },

    // receives each new payload
    update(data, ctx) {
      value.set(utils.formatCount(data && data.unread));
      ctx.setNote(data && data.available ? '' : 'unavailable');
      ctx.setLevel(data && data.unread > 0 ? 'warn' : 'ok');
    }
  });
})();

3. Include the script in src/renderer/index.html

<script src="widgets/whatsapp.js"></script>

Done. The grid adjusts itself (2 columns, equal-height rows).

Resources available to a widget

window.Dashboard.ui:

  • el(tag, className, text) -- creates an element
  • createBar() -- progress bar, returns { el, set(percent) }
  • createBigValue(unit) -- large number with a unit, returns { el, set(text) }
  • createSparkline(pointCount) -- mini history chart in blocks, returns { el, push(percent) } (each call enters from the right, pushing the rest)

window.Dashboard.utils:

  • formatGB, formatPercent, formatCount
  • formatClockTime(iso), formatCountdown(iso), parseDate(iso)
  • levelFor(percent, warnAt, criticalAt) -- returns 'ok' | 'warn' | 'critical'

The ctx received in update has setLevel(level) (changes the card's alert color) and setNote(text) (helper text in the header).


Starting with Windows

Already configured via a Scheduled Task (Task Scheduler), no longer a Startup-folder shortcut -- shortcuts in shell:startup turned out to be unreliable (Windows sometimes just doesn't fire the item on boot/logon, with no error logged at all). The usb-dashboard task runs on the "at logon" trigger (user joaop), with a 15s delay to give the USB screen's driver time to come up, and points at dist\win-unpacked\USB Dashboard.exe, generated by npm run dist -- see the Packaging section.

Why win-unpacked and not the portable .exe: the build also generates dist\usb-dashboard-portable.exe, but that format self-extracts to a new folder in %TEMP% on every run. This already caused auto-start to fail silently (no error in Task Scheduler, LastTaskResult looking successful, but the app never showing up on the screen) -- likely antivirus/SmartScreen scanning a brand-new executable in %TEMP% right at logon, before the rest of the system is fully up. win-unpacked always runs from the same place, without extracting anything, so it doesn't have this problem.

To view/edit the task: Win+R -> taskschd.msc -> Task Scheduler Library -> usb-dashboard. To change the target or remove auto-start, edit or delete the task there (or via PowerShell: Get-ScheduledTask usb-dashboard | Unregister-ScheduledTask).

If you generate a new build (npm run dist again after changing the code), the task keeps working with no reconfiguration needed -- it points at the fixed path dist\win-unpacked\USB Dashboard.exe, which the build always overwrites in the same place. Just remember to copy config.local.json back into dist\win-unpacked\ if npm run dist wiped the whole folder before generating (see config.local.json after packaging).


Limitations and next steps

  • WhatsApp: the new-messages widget doesn't exist yet -- unofficial WhatsApp Web carries an account-ban risk, so that route wasn't taken. The collector + widget + own-page structure is already in place to receive a safer data source in the future.
  • GPU: only the first NVIDIA GPU is read. AMD/Intel cards aren't supported (the widget shows "unavailable").
  • Temperature: depends on LibreHardwareMonitor running with its Remote Web Server on (see "LibreHardwareMonitor widgets"). Without it, the card shows "unavailable" instead of a number -- unlike CPU/RAM/GPU, there's no native Windows fallback for this one.
  • Linear: the widget counts issues assigned to you (viewer.assignedIssues), not the whole team's board. Without a configured key, it shows "unavailable".
  • Packaging: npm run dist generates a portable .exe with no code signing; Windows/SmartScreen may warn "unknown publisher" on first run.

License

PolyForm Noncommercial 1.0.0 -- free to use for noncommercial purposes (personal, study, research, nonprofit organizations). Commercial use requires a separate license from the author.

About

πŸ–₯️ A retro-modern Electron kiosk dashboard for a secondary USB display β€” CPU, RAM, GPU, processes, Claude usage, Linear, WakaTime & Sentry at a glance.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages