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.
- Requirements
- Running it
- Project structure
- How the display is detected
- Where the Claude usage data comes from
- Pages and rotation
- Linear widget (setup)
- WakaTime widget (setup)
- Sentry widget (setup)
- LibreHardwareMonitor widgets (setup)
- Night theme
- Logs
- Long-running robustness (
watchdog.js) - Packaging
- Adding a new widget
- Starting with Windows
- Limitations and next steps
- License
- 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-smion 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
sentryCLI 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.
Double-click start-dashboard.bat (installs dependencies on first run if
needed, then starts the app). Or, from a terminal:
npm install
npm startAvailable 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) |
Since the window has no frame, shortcuts are the only way to control the app:
EscorCtrl+Q-- quits the appCtrl+Shift+I-- opens/closes DevToolsF5orCtrl+R-- reloads the UISpace-- pauses/resumes automatic page rotationLeft arrow/Right arrow-- navigates pages manually (resets that page's timer, so it won't jump on its own right after)
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
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.
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:
- Position + resolution match (with 40px tolerance on position).
- Only the resolution matches, i.e. the screen was moved within Windows' monitor arrangement.
- 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.
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
labelfield (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.
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
pagebecomes its own grid, created the first time a widget declares thatpage. Widgets without apagefall 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) anderrors(Sentry) stay 4-6s each. The values live inPAGE_DURATIONS_MS, insrc/renderer/app.js-- a page with no entry there falls back toDEFAULT_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). Spacepauses/resumes automatic rotation (a pause icon appears in the top bar);Left arrow/Right arrownavigate manually and reset the timer of the page you moved to.
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):
- Copy
.env.exampleto.env(at the project root). - Fill in
LINEAR_API_KEYwith 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:
- Copy
config.local.example.jsontoconfig.local.json(at the project root). - Fill in
linearApiKeywith 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.
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).
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:
- The
sentryCLI installed and on the PATH. - Authenticated (
sentry auth statusto check;sentry auth loginif 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
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:
- Install LibreHardwareMonitor (
winget install LibreHardwareMonitor.LibreHardwareMonitor) and run it as Administrator -- most sensors need elevated access on Windows. - 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. - 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.
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 StateIf it's missing or disabled, open LHM once and re-check Options -> Run On Windows Startup.
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.
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.
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
crashReporteris enabled withuploadToServer: false-- the minidumps stay on disk only, inapp.getPath('crashDumps')(%APPDATA%\usb-dashboard\Crashpadon 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 eventrender-process-gonedoesn't cover) and reloads the window when the type isGPU. - Fast crash loop: if
uncaughtException/unhandledRejectionfires again within 30s of a previous abnormal exit, thecrashStreakcounter (persisted in the samewatchdog-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 affectcrashStreak.
All the constants (memory threshold, relaunch hour, fast-crash window, etc.)
live in WATCHDOG in src/main/config.js.
npm run distGenerates 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.exethat 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.
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: putconfig.local.jsoninsidedist\win-unpacked\(same folder asUSB Dashboard.exe). - running via
usb-dashboard-portable.exe: putconfig.local.jsonnext to the portable.exe(e.g. directly indist\) -- electron-builder exportsPORTABLE_EXECUTABLE_DIRpointing 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.
Widgets are independent: each one has its own file and its own update cadence. Adding a new widget doesn't touch existing ones.
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.
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');
}
});
})();<script src="widgets/whatsapp.js"></script>Done. The grid adjusts itself (2 columns, equal-height rows).
window.Dashboard.ui:
el(tag, className, text)-- creates an elementcreateBar()-- 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,formatCountformatClockTime(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).
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).
- 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 distgenerates a portable.exewith no code signing; Windows/SmartScreen may warn "unknown publisher" on first run.
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.

