P02: System Monitor Dashboard

P02: System Monitor Dashboard

Build a multi-action Stream Deck plugin that displays real-time CPU usage, memory consumption, and disk space on dedicated keys with color-coded thresholds and configurable polling intervals.


Overview

Attribute Value
Difficulty Intermediate
Time Estimate 1 week
Language TypeScript (recommended) or JavaScript
Prerequisites JavaScript/TypeScript, basic understanding of system metrics, Project 1 completion
Primary Book “Node.js Design Patterns” by Casciaro & Mammino
Software Elgato Stream Deck SDK, Node.js, systeminformation library

Learning Objectives

By completing this project, you will:

  1. Master multi-action plugin architecture - Understand how to define and manage multiple action types within a single plugin, each with its own UUID, icon, and behavior
  2. Implement efficient polling patterns - Learn setInterval management, resource cleanup, and the tradeoffs between update frequency and system overhead
  3. Integrate Node.js system APIs - Use the systeminformation library to query CPU load, memory usage, and disk space across platforms
  4. Create dynamic canvas-based visualizations - Draw arc gauges, progress bars, and numeric displays on 72x72/144x144 pixel canvases
  5. Build color interpolation systems - Implement HSL-based color gradients that smoothly transition between threshold states
  6. Handle lifecycle management - Properly start and stop polling when actions appear/disappear from the deck
  7. Design configurable settings UI - Create Property Inspector interfaces for per-action threshold and display customization

Measurable Outcomes

Upon completion, you should be able to:

  • Explain how Stream Deck routes events to different action classes within a single plugin
  • Calculate optimal polling intervals based on human perception and system overhead
  • Implement a shared polling service that multiple action instances can subscribe to
  • Debug memory leaks caused by orphaned interval timers
  • Trace a metric from systeminformation call through canvas rendering to setImage

Real World Outcome

A genuinely useful productivity tool you will use daily:

  • Live CPU percentage displayed on one key (turns red above 80%)
  • Memory usage on another key (color gradients based on pressure)
  • Disk space indicator that warns when storage is low
  • Optional network throughput display with upload/download indicators
+---------------------------------------------------------------------------+
|                     STREAM DECK SYSTEM MONITOR DASHBOARD                   |
+---------------------------------------------------------------------------+
|                                                                            |
|   Your Stream Deck with multiple system monitoring keys:                   |
|                                                                            |
|   +----------+  +----------+  +----------+  +----------+  +----------+    |
|   |          |  |          |  |          |  |          |  |  OTHER   |    |
|   |   .---.  |  |   .---.  |  |   .---.  |  |   .---.  |  |   KEY    |    |
|   |  /     \ |  |  /  |  \ |  |  / .|. \ |  |  /     \ |  |          |    |
|   | |       ||  | |   |   ||  | | .|.|. ||  | |       ||  |          |    |
|   |  \     / |  |  \  |  / |  |  \ '|' / |  |  \     / |  |          |    |
|   |   '---'  |  |   '---'  |  |   '---'  |  |   '---'  |  |          |    |
|   |  CPU 23% |  |  RAM 67% |  | DISK 89% |  |  NET ^v  |  |          |    |
|   | [GREEN]  |  | [YELLOW] |  |  [RED]   |  | [BLUE]   |  |          |    |
|   +----------+  +----------+  +----------+  +----------+  +----------+    |
|                                                                            |
|   +----------+  +----------+  +----------+  +----------+  +----------+    |
|   |          |  |          |  |          |  |          |  |          |    |
|   |          |  |          |  |          |  |          |  |          |    |
|   |          |  |          |  |          |  |          |  |          |    |
|   |          |  |          |  |          |  |          |  |          |    |
|   |          |  |          |  |          |  |          |  |          |    |
|   +----------+  +----------+  +----------+  +----------+  +----------+    |
|                                                                            |
+---------------------------------------------------------------------------+
|                          COLOR THRESHOLD LEGEND                            |
|                                                                            |
|   CPU Key States:                                                          |
|   +----------+  +----------+  +----------+                                 |
|   |          |  |          |  |          |                                 |
|   |    23%   |  |    67%   |  |    89%   |                                 |
|   |  GREEN   |  |  YELLOW  |  |   RED    |                                 |
|   | (0-50%)  |  | (51-80%) |  |(81-100%) |                                 |
|   +----------+  +----------+  +----------+                                 |
|                                                                            |
+---------------------------------------------------------------------------+

Stream Deck System Monitor Dashboard

Gauge Visualization Detail (144x144 canvas for @2x)

       Arc Gauge Anatomy
       =================

              Start Angle
              (0.75 * PI)
                 |
                 v
             .-------.
           /           \
         /               \
        |                 |
        |     67%         |  <-- Percentage text centered
        |     RAM         |  <-- Label below percentage
        |                 |
         \               /
           \           /
             '-------'
                 ^
                 |
              End Angle
              (2.25 * PI)

       The arc sweeps from 7 o'clock to 5 o'clock position,
       filling clockwise based on the metric percentage.

       Canvas Coordinate System:
       +------------------------+
       |(0,0)              (w,0)|
       |                        |
       |      (cx,cy) = center  |
       |           *            |
       |                        |
       |(0,h)              (w,h)|
       +------------------------+

       Arc Parameters:
       - centerX = width / 2 = 72
       - centerY = height / 2 = 72
       - radius = width * 0.35 = 50.4
       - startAngle = 0.75 * PI (135 degrees)
       - endAngle = 2.25 * PI (405 degrees)
       - arcLength = 1.5 * PI (270 degrees total)

Arc Gauge Anatomy

Property Inspector Configuration

+---------------------------------------------------------------------------+
|                    System Monitor Configuration                            |
+---------------------------------------------------------------------------+
|                                                                            |
|  Polling Interval                                                          |
|  +---------------------------------------------------+                     |
|  | 2000                                          [v] | ms                  |
|  +---------------------------------------------------+                     |
|  (i) Lower values = more CPU usage. Recommended: 1000-5000ms               |
|                                                                            |
|  ---------------------------------------------------------------           |
|                                                                            |
|  Warning Threshold (Yellow)                                                |
|  +---------------------------------------------------+                     |
|  | 50                                            [v] | %                   |
|  +---------------------------------------------------+                     |
|                                                                            |
|  Critical Threshold (Red)                                                  |
|  +---------------------------------------------------+                     |
|  | 80                                            [v] | %                   |
|  +---------------------------------------------------+                     |
|                                                                            |
|  ---------------------------------------------------------------           |
|                                                                            |
|  Display Style                                                             |
|  ( ) Percentage Only                                                       |
|  (*) Gauge with Percentage                                                 |
|  ( ) Bar Graph                                                             |
|                                                                            |
|  ---------------------------------------------------------------           |
|                                                                            |
|  [x] Show label below value                                                |
|  [x] Animate threshold transitions                                         |
|  [ ] Flash on critical                                                     |
|                                                                            |
+---------------------------------------------------------------------------+

Property Inspector Configuration UI


The Core Question You’re Answering

“How do I create a Stream Deck plugin with multiple action types that continuously poll external data sources and display that information visually with dynamic, color-coded feedback?”

This project forces you to solve the fundamental challenge of multi-action plugin architecture. Unlike Project 1 where you had one action doing one thing, here you are building a dashboard where CPU, RAM, and Disk each have their own action class, their own settings, their own visual representations—but they all share a common polling infrastructure and rendering pipeline.

The deeper question is about resource management: How do you balance update frequency against system load? How do you avoid hammering the CPU just to display CPU usage? This tension between real-time accuracy and efficient polling is a pattern you will encounter in every monitoring tool you ever build.

Sub-Questions This Project Answers

  1. Architecture: How do you organize code when multiple actions share similar logic but have different data sources?
  2. Performance: What polling interval balances responsiveness against battery/CPU impact?
  3. Resource Lifecycle: How do you ensure timers are cleaned up when keys are removed from the deck?
  4. Visual Design: How do you encode numeric data into color and shape for at-a-glance understanding?
  5. Error Handling: What happens when systeminformation fails? How do you degrade gracefully?

Deep Theoretical Foundation

Part 1: Multi-Action Plugin Architecture

How Multiple Actions Work in manifest.json

A single plugin can expose multiple “actions” that appear as separate options in the Stream Deck action list. Each action needs its own unique identifier and can have completely different behavior.

manifest.json Structure for Multi-Action Plugins
================================================

{
  "Name": "System Monitor",
  "Version": "1.0.0",
  "SDKVersion": 2,
  "Author": "Your Name",
  "Actions": [                    <-- Array of action definitions
    {
      "Name": "CPU Monitor",
      "UUID": "com.yourname.systemmonitor.cpu",     <-- Unique identifier
      "Icon": "images/cpu",
      "States": [{ "Image": "images/cpu" }],
      "PropertyInspectorPath": "pi/cpu.html"       <-- Can have its own PI
    },
    {
      "Name": "RAM Monitor",
      "UUID": "com.yourname.systemmonitor.ram",     <-- Different UUID
      "Icon": "images/ram",
      "States": [{ "Image": "images/ram" }],
      "PropertyInspectorPath": "pi/ram.html"       <-- Or shared PI
    },
    {
      "Name": "Disk Monitor",
      "UUID": "com.yourname.systemmonitor.disk",
      "Icon": "images/disk",
      "States": [{ "Image": "images/disk" }],
      "PropertyInspectorPath": "pi/disk.html"
    }
  ],
  "CodePath": "bin/plugin.js",    <-- Single entry point handles all actions
  ...
}

Event Routing: How Stream Deck Knows Which Action to Call

When an event occurs (key press, willAppear, etc.), the Stream Deck application sends a JSON message that includes which action it pertains to:

Event Routing Flow
==================

User adds CPU key to deck
         |
         v
+-----------------------+
| Stream Deck App       |
| sends willAppear with |
| action: "...cpu"      |
+-----------------------+
         |
         v
+-----------------------+
| Your Plugin Backend   |
| receives JSON:        |
| {                     |
|   "event": "willAppear",
|   "action": "com.yourname.systemmonitor.cpu",
|   "context": "ABC123",
|   ...                 |
| }                     |
+-----------------------+
         |
         v
+-----------------------+
| Stream Deck SDK       |
| routes to CpuAction   |
| instance based on     |
| action UUID match     |
+-----------------------+

The SDK’s routing mechanism matches the action field in incoming messages to your registered action classes. You register them like this:

// In your plugin entry point
streamDeck.actions.registerAction(new CpuAction());
streamDeck.actions.registerAction(new RamAction());
streamDeck.actions.registerAction(new DiskAction());

// Each action class declares which UUID it handles
@action({ UUID: "com.yourname.systemmonitor.cpu" })
class CpuAction extends SingletonAction { ... }

Multiple Instances of the Same Action

A crucial concept: users can add multiple keys of the same action type. Two CPU monitors on the same deck means two instances of CpuAction, each with its own context identifier.

Multiple Instance Scenario
==========================

Stream Deck Layout:
+------+------+------+
| CPU  | RAM  | CPU  |  <-- TWO CPU keys!
| #1   |      | #2   |
+------+------+------+

Runtime State:
- CpuAction instance 1: context = "ctx_abc"
- CpuAction instance 2: context = "ctx_xyz"
- RamAction instance 1: context = "ctx_def"

Each has its own:
- Settings (thresholds, display style)
- Polling timer (or shared? design decision!)
- Current displayed value

This raises an important design question: Should each instance poll independently, or should there be a single polling service that broadcasts to all interested instances?


Part 2: Polling Patterns and setInterval Management

The Polling Paradigm

Polling is the act of repeatedly asking “what is the current value?” at fixed intervals. It is the opposite of event-driven (where the system notifies you of changes).

Polling vs Event-Driven
=======================

Polling:
  You: "What's the CPU?"    (t=0)
  OS:  "45%"
  You: "What's the CPU?"    (t=1000ms)
  OS:  "47%"
  You: "What's the CPU?"    (t=2000ms)
  OS:  "42%"
  ...

Event-Driven (hypothetical):
  OS:  "CPU changed to 45%"
  OS:  "CPU changed to 47%"
  OS:  "CPU changed to 42%"
  ...

System metrics typically require polling because:
1. The OS does not expose metric change events
2. The values change continuously (not discrete events)
3. You want control over sampling frequency

setInterval Mechanics and the Event Loop

Understanding how setInterval interacts with Node.js’s event loop is essential:

Node.js Event Loop with setInterval
===================================

         +------------------+
         |   Call Stack     |
         |  (sync code)     |
         +--------+---------+
                  |
                  v
    +-------------+-------------+
    |                           |
    v                           v
+-------+                 +----------+
| Timer |                 | I/O      |
| Queue |                 | Queue    |
+---+---+                 +----+-----+
    |                          |
    +------------+-------------+
                 |
                 v
         +-------+-------+
         |  Event Loop   |
         | (processes    |
         |  one item at  |
         |  a time)      |
         +---------------+

setInterval(callback, 2000):
1. Registers a recurring timer
2. Every 2000ms, adds callback to timer queue
3. Event loop picks it up when call stack is empty

DANGER: If your callback takes longer than the interval,
        callbacks will queue up (memory pressure) or overlap!

The Overlapping Calls Problem

Consider this scenario:

Overlapping Poll Problem
========================

Polling interval: 2000ms
systeminformation.currentLoad() takes: 500ms (usually)
                                       2500ms (sometimes, under load!)

Normal case:
|--poll1--|            |--poll2--|            |--poll3--|
0ms      500ms        2000ms    2500ms        4000ms

Problem case (slow system call):
|--------poll1--------|
|--poll2--|            <-- Started before poll1 finished!
0ms      2000ms      2500ms

This can cause:
- Memory growth (queued callbacks)
- Race conditions (out-of-order results)
- CPU spike (multiple concurrent system queries)

Solution Patterns for Safe Polling

Pattern 1: Guard Flag (Mutex-like)

class CpuAction {
    private isPolling = false;

    private async pollAndRender(): Promise<void> {
        if (this.isPolling) return; // Skip if previous poll still running
        this.isPolling = true;
        try {
            const cpu = await si.currentLoad();
            await this.renderGauge(cpu.currentLoad);
        } finally {
            this.isPolling = false;
        }
    }
}

Pattern 2: setTimeout Chain (Self-Rescheduling)

class CpuAction {
    private pollTimer?: NodeJS.Timeout;

    private schedulePoll(): void {
        this.pollTimer = setTimeout(async () => {
            await this.pollAndRender();
            this.schedulePoll(); // Reschedule after completion
        }, 2000);
    }

    // This guarantees exactly 2000ms between END of one poll
    // and START of the next (never overlapping)
}

Pattern 3: Shared Polling Service

Shared Polling Architecture
===========================

Instead of each action polling independently:

    Bad: Independent Polling
    +---------+  +---------+  +---------+
    | CPU #1  |  | CPU #2  |  | RAM     |
    | poll()  |  | poll()  |  | poll()  |
    | poll()  |  | poll()  |  | poll()  |
    +---------+  +---------+  +---------+
         |           |            |
         v           v            v
    systeminformation called 3x every interval!

    Good: Shared Polling Service
    +--------------------+
    | SystemMetricsService|
    | poll() once        |
    | emits 'metrics'    |
    +--------------------+
         |
         +-------+-------+
         v       v       v
    +---------+ +---------+ +---------+
    | CPU #1  | | CPU #2  | | RAM     |
    | listen  | | listen  | | listen  |
    +---------+ +---------+ +---------+

    Result: One system call serves all consumers

Part 3: Node.js systeminformation Library

What systeminformation Provides

The systeminformation npm package provides cross-platform access to hardware and OS metrics. It works on Windows, macOS, and Linux.

systeminformation API Overview
==============================

CPU Metrics:
  si.cpu()           -> CPU hardware info (brand, cores, speed)
  si.cpuCurrentSpeed() -> Current frequency
  si.currentLoad()   -> CPU usage percentage (this is what you want!)
  si.fullLoad()      -> CPU load with per-core breakdown

Memory Metrics:
  si.mem()           -> Memory usage in bytes
                       { total, used, free, active, available, ... }

Disk Metrics:
  si.fsSize()        -> Filesystem info (array of disks)
                       [{ fs, size, used, available, use, mount }, ...]
  si.diskIO()        -> Read/write operations per second

Network Metrics:
  si.networkStats()  -> Network throughput
                       { rx_sec, tx_sec, ... }

All functions return Promises (async/await compatible)

Data Shape Examples

Understanding the exact shape of returned data is critical for your rendering logic:

// CPU Load
const cpu = await si.currentLoad();
console.log(cpu);
// {
//   avgLoad: 45.2,           // Average over sampling period
//   currentLoad: 67.5,       // Current total CPU usage (0-100)
//   currentLoadUser: 45.1,   // User processes
//   currentLoadSystem: 22.4, // System processes
//   currentLoadNice: 0,
//   currentLoadIdle: 32.5,
//   cpus: [                  // Per-core breakdown
//     { load: 70.2, ... },
//     { load: 64.8, ... },
//     ...
//   ]
// }

// Memory
const mem = await si.mem();
console.log(mem);
// {
//   total: 17179869184,      // 16 GB in bytes
//   used: 12884901888,       // 12 GB used
//   free: 4294967296,        // 4 GB free
//   active: 10737418240,
//   available: 6442450944,
//   ...
// }
const memPercent = (mem.used / mem.total) * 100; // 75%

// Disk
const disks = await si.fsSize();
console.log(disks);
// [
//   {
//     fs: '/dev/disk1s1',
//     type: 'APFS',
//     size: 499963174912,     // 500 GB total
//     used: 299977904947,     // 300 GB used
//     available: 199985269965,
//     use: 60.0,              // Already calculated percentage!
//     mount: '/'
//   },
//   { fs: '/dev/disk2s1', ... }  // External drives
// ]

Performance Considerations

Different systeminformation calls have different costs:

systeminformation Performance Profile
=====================================

Fast (~10-50ms):
  si.currentLoad()    - CPU sampling
  si.mem()            - Memory stats
  si.networkStats()   - Network counters

Medium (~50-200ms):
  si.fsSize()         - Disk enumeration
  si.processes()      - Process list

Slow (~200-1000ms):
  si.diskIO()         - Requires sampling period
  si.fullLoad()       - Comprehensive CPU analysis

Strategy:
  - Use fast calls for frequent polling (1-2s)
  - Use slow calls sparingly or with longer intervals
  - Consider caching expensive calls

Part 4: Canvas Arc/Gauge Rendering

Canvas Basics for Stream Deck

Stream Deck keys are small: 72x72 pixels (or 144x144 for @2x retina). The node-canvas package brings the HTML Canvas API to Node.js.

Canvas Coordinate System
========================

+--------------------------------+
|(0,0)                    (144,0)|
|                                |
|                                |
|            (72,72)             |  <- Center point
|               *                |
|                                |
|                                |
|(0,144)                (144,144)|
+--------------------------------+

Note: Y increases downward (opposite of math convention)
Angles: 0 radians = 3 o'clock, PI/2 = 6 o'clock (clockwise)

Arc Drawing Deep Dive

The ctx.arc() method is the foundation of gauge rendering:

ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise)
==========================================================

Parameters:
  x, y          - Center point of the arc
  radius        - Distance from center to arc
  startAngle    - Starting angle in radians (0 = 3 o'clock)
  endAngle      - Ending angle in radians
  anticlockwise - false = clockwise, true = counterclockwise

Angle Reference:
               0 (3 o'clock)
               |
    3PI/2      |      PI/2
    (12 o'clock)      (6 o'clock)
               |
               PI (9 o'clock)

For a gauge from 7 o'clock to 5 o'clock (270 degree sweep):
  startAngle = 0.75 * PI  (135 degrees, between 9 and 6 o'clock)
  endAngle   = 2.25 * PI  (405 degrees, wraps to 45 degrees)

Building a Gauge Step by Step

import { createCanvas } from 'canvas';

function renderGauge(percentage: number, color: string, label: string): string {
    // 1. Create canvas (144x144 for @2x retina)
    const size = 144;
    const canvas = createCanvas(size, size);
    const ctx = canvas.getContext('2d');

    // 2. Fill background
    ctx.fillStyle = '#1a1a2e';
    ctx.fillRect(0, 0, size, size);

    // 3. Define arc geometry
    const centerX = size / 2;
    const centerY = size / 2;
    const radius = size * 0.35;
    const lineWidth = 14;
    const startAngle = 0.75 * Math.PI;  // 7 o'clock
    const endAngle = 2.25 * Math.PI;    // 5 o'clock
    const arcLength = endAngle - startAngle;

    // 4. Draw background arc (gray track)
    ctx.beginPath();
    ctx.arc(centerX, centerY, radius, startAngle, endAngle);
    ctx.strokeStyle = '#333333';
    ctx.lineWidth = lineWidth;
    ctx.lineCap = 'round';
    ctx.stroke();

    // 5. Draw value arc (colored fill)
    const valueAngle = startAngle + (percentage / 100) * arcLength;
    ctx.beginPath();
    ctx.arc(centerX, centerY, radius, startAngle, valueAngle);
    ctx.strokeStyle = color;
    ctx.stroke();

    // 6. Draw percentage text
    ctx.fillStyle = '#ffffff';
    ctx.font = 'bold 36px Arial';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(`${Math.round(percentage)}%`, centerX, centerY - 8);

    // 7. Draw label
    ctx.font = '16px Arial';
    ctx.fillText(label, centerX, centerY + 20);

    // 8. Return as data URL for setImage
    return canvas.toDataURL('image/png');
}

Visual Guide: Gauge Construction

Gauge Construction Layers
=========================

Layer 1: Background
+------------------+
|                  |
|  Dark fill       |
|  (#1a1a2e)       |
|                  |
+------------------+

Layer 2: Track Arc (gray)
+------------------+
|                  |
|      .---.       |
|    /       \     |  <- 270 degree arc
|   |         |    |     gray background track
|    \       /     |
|      '---'       |
+------------------+

Layer 3: Value Arc (colored)
+------------------+
|                  |
|      .---.       |
|    /      |      |  <- Partial arc based on percentage
|   |       |      |     colored by threshold
|    \             |
|                  |
+------------------+

Layer 4: Text
+------------------+
|                  |
|      .---.       |
|    /  67% \      |  <- Percentage centered
|   |  RAM   |     |  <- Label below
|    \       /     |
|      '---'       |
+------------------+

Part 5: Color-Coded Thresholds and HSL Interpolation

Why Color Matters

Color is the fastest channel for conveying meaning to humans. A glance at a colored gauge tells you more than reading a number.

Color Psychology for Monitoring
===============================

Green  (#4ade80) - "All is well" - low usage, healthy
Yellow (#facc15) - "Attention" - moderate usage, warning
Orange (#f97316) - "Caution" - elevated usage, concerning
Red    (#ef4444) - "Problem" - high usage, critical

Threshold Logic:
  0-50%   -> Green (normal operation)
  50-80%  -> Yellow to Orange gradient (watch it)
  80-100% -> Red (investigate/act)

RGB vs HSL for Gradients

RGB interpolation produces muddy intermediate colors. HSL produces perceptually smooth gradients.

RGB Interpolation Problem
=========================

Interpolating Red (#FF0000) to Green (#00FF00) through RGB:

t=0.0: #FF0000 (Red)
t=0.5: #808000 (Muddy olive - ugly!)
t=1.0: #00FF00 (Green)

The "path" through RGB color space goes through unpleasant colors.

HSL Interpolation Solution
==========================

HSL = Hue, Saturation, Lightness

Red:   H=0,   S=100%, L=50%
Yellow: H=60,  S=100%, L=50%
Green:  H=120, S=100%, L=50%

Interpolating just the Hue gives us:
t=0.0: H=0   (Red)
t=0.5: H=60  (Yellow - vibrant!)
t=1.0: H=120 (Green)

Much better! The path stays in the "rainbow" belt.

Color Calculation Implementation

interface Thresholds {
    warning: number;   // e.g., 50
    critical: number;  // e.g., 80
}

function getColorForValue(value: number, thresholds: Thresholds): string {
    // Below warning threshold: solid green
    if (value < thresholds.warning) {
        return '#4ade80';
    }

    // Between warning and critical: interpolate yellow to orange
    if (value < thresholds.critical) {
        const range = thresholds.critical - thresholds.warning;
        const t = (value - thresholds.warning) / range;
        return interpolateHSL(
            { h: 48, s: 96, l: 53 },  // Yellow (#facc15)
            { h: 25, s: 95, l: 53 },  // Orange (#f97316)
            t
        );
    }

    // Above critical: solid red
    return '#ef4444';
}

interface HSL {
    h: number;  // 0-360
    s: number;  // 0-100
    l: number;  // 0-100
}

function interpolateHSL(color1: HSL, color2: HSL, t: number): string {
    const h = color1.h + (color2.h - color1.h) * t;
    const s = color1.s + (color2.s - color1.s) * t;
    const l = color1.l + (color2.l - color1.l) * t;
    return `hsl(${h}, ${s}%, ${l}%)`;
}

Visual: Color Gradient Mapping

Color Gradient for CPU Usage
============================

  0%        50%       80%       100%
  |----------|----------|---------|
  |  GREEN   | Y -> O   |   RED   |
  |          | gradient |         |
  |  Normal  | Warning  | Critical|

Color at specific values:
  25% -> #4ade80 (green)
  50% -> #facc15 (yellow, exactly at warning)
  65% -> #f8a716 (interpolated between yellow and orange)
  80% -> #f97316 (orange, exactly at critical)
  90% -> #ef4444 (red)

Part 6: Resource Cleanup on willDisappear

The Memory Leak Problem

If you start a setInterval timer when a key appears but forget to clear it when the key disappears, you create a “zombie” timer that continues running forever.

Memory Leak Scenario
====================

Time 0: User adds CPU key
        -> CpuAction.onWillAppear()
        -> setInterval(poll, 2000) started

Time 5s: User removes CPU key from deck
        -> CpuAction.onWillDisappear() should be called
        -> BUT if you forgot to clearInterval()...

Time 10s: Timer still running!
         -> pollAndRender() called
         -> Tries to update a key that no longer exists
         -> setImage() may fail silently

Time 1 hour: 1800 zombie poll calls later
            -> Memory leaked
            -> CPU wasted
            -> Eventually: out of memory error

Solution: ALWAYS clear timers in onWillDisappear

Proper Lifecycle Management

class CpuAction extends SingletonAction {
    // Timer reference - must be instance variable
    private pollTimer?: NodeJS.Timeout;

    // Called when key becomes visible on deck
    override onWillAppear(ev: WillAppearEvent): void {
        console.log('CPU key appeared, starting polling');
        this.startPolling();
    }

    // Called when key is removed or deck switches profiles
    override onWillDisappear(ev: WillDisappearEvent): void {
        console.log('CPU key disappeared, stopping polling');
        this.stopPolling();
    }

    private startPolling(): void {
        // Clear any existing timer (safety)
        this.stopPolling();

        // Initial poll immediately
        this.pollAndRender();

        // Then poll at interval
        this.pollTimer = setInterval(() => {
            this.pollAndRender();
        }, this.getPollingInterval());
    }

    private stopPolling(): void {
        if (this.pollTimer) {
            clearInterval(this.pollTimer);
            this.pollTimer = undefined;
            console.log('Polling timer cleared');
        }
    }

    // Called when settings change (user adjusts interval in PI)
    override onDidReceiveSettings(ev: DidReceiveSettingsEvent): void {
        // Restart polling with new interval
        if (this.pollTimer) {
            this.stopPolling();
            this.startPolling();
        }
    }
}

Lifecycle Event Sequence

Complete Lifecycle Sequence
===========================

1. User drags CPU action to deck key
   +-> Stream Deck sends: willAppear
   +-> Your code: startPolling()

2. Every 2000ms (while key visible)
   +-> Timer fires: pollAndRender()
   +-> systeminformation call
   +-> Canvas rendering
   +-> setImage() to Stream Deck

3. User opens Property Inspector
   +-> No lifecycle impact (polling continues)

4. User changes polling interval in PI
   +-> Stream Deck sends: didReceiveSettings
   +-> Your code: restart polling with new interval

5. User switches to different profile
   +-> Stream Deck sends: willDisappear
   +-> Your code: stopPolling()
   +-> Timer cleared, no more polls

6. User switches back to original profile
   +-> Stream Deck sends: willAppear
   +-> Your code: startPolling() (fresh start)

Concepts You Must Understand First

Before writing any code, ensure you have solid mental models for these concepts:

1. Multi-Action Manifest Configuration

Your manifest.json must define multiple actions within a single plugin. Each action (CPU, RAM, Disk) needs its own UUID, icon, and action class. Understanding how Stream Deck routes events to the correct action handler is essential.

Resources:

  • Manifest Reference - Elgato official documentation
  • Node.js Design Patterns (Ch. 8: “Structural Design Patterns”) - How to organize code for multiple similar-but-different components

2. Polling Patterns and Intervals

Polling is the act of repeatedly asking “what is the current value?” at fixed intervals. You will use setInterval() but need to understand the tradeoffs:

  • Too fast (100ms) = high CPU overhead, battery drain
  • Too slow (10s) = stale data, missed spikes
  • Just right (1-3s) = acceptable for human perception

Resources:

  • Node.js Design Patterns (Ch. 3: “Callbacks and Events”) - Event loop behavior with timers
  • Node.js Design Patterns (Ch. 11: “Advanced Recipes”) - Managing async operations over time

3. Node.js System Information APIs

The systeminformation library provides cross-platform access to CPU load, memory usage, disk space, network stats, and more. Understanding its async API and the shape of returned data structures is critical.

Resources:

  • systeminformation npm package - API reference and examples
  • Node.js Design Patterns (Ch. 5: “Asynchronous Control Flow Patterns with Promises and Async/Await”)

4. Color-Coded Thresholds and Gradient Rendering

You are not just showing numbers—you are encoding meaning through color. This requires understanding:

  • HSL color space (why it is better for gradients than RGB)
  • Linear interpolation between colors
  • Threshold logic (if value > X, use color Y)

Resources:

  • HTML5 Canvas by Steve Fulton (Ch. 4: “Images on the Canvas”) - Color manipulation fundamentals
  • Refactoring UI by Wathan & Schoger (“Color” section) - Choosing effective UI colors

5. Canvas Drawing for Gauges and Meters

Stream Deck keys are 72x72 or 144x144 pixels. You will draw arc gauges, progress bars, or numeric displays using the HTML Canvas API (via node-canvas). This is programmatic image generation, not static assets.

Resources:

  • node-canvas npm package - Canvas in Node.js
  • HTML5 Canvas by Steve Fulton (Ch. 2: “Drawing on the Canvas”, Ch. 5: “Making Things Move”)

Complete Project Specification

What You Are Building

A multi-action Stream Deck plugin that provides real-time system monitoring with:

  1. CPU Monitor Action - Displays current CPU usage percentage with color-coded threshold indicators
  2. RAM Monitor Action - Displays memory usage percentage with visual gauge
  3. Disk Monitor Action - Displays primary disk usage with low-space warnings

Minimum Requirements

Manifest and Structure

  • manifest.json with three action definitions (CPU, RAM, Disk)
  • Each action has unique UUID, icon, and optional PropertyInspector
  • Single plugin.ts entry point that registers all actions

CPU Monitor Action

  • Displays CPU usage percentage (0-100%)
  • Updates at configurable interval (default: 2000ms)
  • Green (<50%), Yellow (50-80%), Red (>80%) color coding
  • Visual arc gauge showing fill level
  • Numeric percentage displayed in center

RAM Monitor Action

  • Displays memory usage as percentage of total
  • Same color threshold system as CPU
  • Shows “RAM” label below percentage
  • Properly calculates used/total ratio

Disk Monitor Action

  • Displays primary disk (/) usage percentage
  • Warnings when disk is nearly full (>90%)
  • Shows “DISK” label below percentage
  • Handles multiple disks (uses first/primary)

Property Inspector

  • Polling interval configuration (1000-10000ms range)
  • Warning threshold slider (0-100%)
  • Critical threshold slider (0-100%)
  • Validation: warning < critical
  • Display style selector (gauge, percentage only, bar)

Error Handling

  • Graceful handling of systeminformation failures
  • Display “N/A” or last known value on error
  • Log errors for debugging

Resource Management

  • Clear all intervals on willDisappear
  • Restart polling on willAppear
  • Update polling when settings change

Stretch Goals

  • Network Monitor action (upload/download speeds)
  • GPU Monitor action (if available)
  • Per-core CPU breakdown (multi-key dashboard)
  • Historical sparkline graphs
  • System temperature monitoring
  • Custom threshold sounds/alerts

Solution Architecture

Class Structure

Class Hierarchy
===============

+-------------------+
|  SingletonAction  |  <- Stream Deck SDK base class
+-------------------+
         |
         v
+-------------------+
| MonitorAction     |  <- Your abstract base class
| (abstract)        |
+-------------------+
| - pollTimer       |
| - settings        |
| # startPolling()  |
| # stopPolling()   |
| # renderGauge()   |
| # getColor()      |
| + abstract poll() |
+-------------------+
         |
   +-----+-----+-----+
   |           |     |
   v           v     v
+-------+ +-------+ +-------+
|CpuAct | |RamAct | |DiskAct|
+-------+ +-------+ +-------+
|poll() | |poll() | |poll() |
|  cpu  | |  mem  | | disk  |
+-------+ +-------+ +-------+

File Structure

com.yourname.systemmonitor.sdPlugin/
+-- manifest.json
+-- bin/
|   +-- plugin.js           <- Compiled entry point
+-- src/
|   +-- plugin.ts           <- Entry point, registers actions
|   +-- actions/
|   |   +-- MonitorAction.ts   <- Abstract base class
|   |   +-- CpuAction.ts       <- CPU-specific implementation
|   |   +-- RamAction.ts       <- RAM-specific implementation
|   |   +-- DiskAction.ts      <- Disk-specific implementation
|   +-- services/
|   |   +-- MetricsService.ts  <- Shared polling service (optional)
|   |   +-- CanvasRenderer.ts  <- Gauge rendering utilities
|   |   +-- ColorUtils.ts      <- Color interpolation
|   +-- types/
|       +-- settings.ts        <- TypeScript interfaces
+-- pi/
|   +-- cpu.html              <- Property Inspector for CPU
|   +-- ram.html              <- Property Inspector for RAM
|   +-- disk.html             <- Property Inspector for Disk
|   +-- shared.css            <- Shared styles
|   +-- shared.js             <- Shared PI utilities
+-- images/
    +-- cpu.png               <- Action icon
    +-- ram.png
    +-- disk.png
    +-- category.png          <- Plugin category icon

Shared Polling Service Design (Advanced)

For efficiency, instead of each action polling independently, consider a shared service:

Shared Metrics Service Pattern
==============================

                +-------------------+
                | MetricsService    |
                | (Singleton)       |
                +-------------------+
                | - interval        |
                | - subscribers     |
                | + subscribe(cb)   |
                | + unsubscribe(cb) |
                | + start()         |
                | + stop()          |
                | - poll()          |
                +-------------------+
                         |
            poll() calls |
            si.currentLoad() + si.mem() + si.fsSize()
            in single batched call
                         |
                         v
                +-------------------+
                | Event: 'metrics'  |
                | { cpu, mem, disk }|
                +-------------------+
                   /     |     \
                  v      v      v
            +------+ +------+ +------+
            |CpuAct| |RamAct| |DiskAct|
            |.on() | |.on() | |.on() |
            +------+ +------+ +------+

Benefits:
1. Single systeminformation call serves all actions
2. Automatic start/stop based on subscriber count
3. Easy to add new metric types
4. No duplicate polling if multiple instances of same action

Phased Implementation Guide

Phase 1: Project Setup (Day 1)

Goal: Create plugin skeleton with multi-action manifest

Tasks:

  1. Create plugin directory structure
  2. Write manifest.json with three actions defined
  3. Set up TypeScript compilation
  4. Install dependencies: @elgato/streamdeck, systeminformation, canvas
  5. Create placeholder action classes that log events
  6. Verify plugin loads in Stream Deck app

Verification:

  • Plugin appears in Stream Deck action list
  • All three actions (CPU, RAM, Disk) are visible
  • Adding a key logs “willAppear” to console
  • Removing a key logs “willDisappear” to console

Phase 2: System Information Integration (Day 2)

Goal: Fetch and log system metrics

Tasks:

  1. Create a test script that calls systeminformation functions
  2. Understand data shape of si.currentLoad(), si.mem(), si.fsSize()
  3. Implement poll() method in each action class
  4. Call poll() once in onWillAppear and log results

Verification:

// In console, should see:
// CPU: 45.2%
// RAM: 67.3% (11.2 GB / 16.0 GB)
// Disk: 72.1% (361 GB / 500 GB)

Phase 3: Polling Loop with Cleanup (Day 3)

Goal: Implement continuous polling with proper lifecycle

Tasks:

  1. Add pollTimer instance variable to action classes
  2. Start timer in onWillAppear
  3. Clear timer in onWillDisappear
  4. Add guard against overlapping polls
  5. Test by adding/removing keys rapidly

Verification:

  • Console shows “Poll…” message every 2 seconds
  • Removing key stops the polling immediately
  • Adding key back starts fresh polling
  • No “zombie” polls after key removal

Phase 4: Canvas Gauge Rendering (Days 4-5)

Goal: Draw visual gauges on keys

Tasks:

  1. Install and configure node-canvas
  2. Create renderGauge() function that draws arc
  3. Implement background arc (gray track)
  4. Implement value arc (colored based on percentage)
  5. Add percentage text in center
  6. Add label below percentage
  7. Call setImage() with canvas data URL

Verification:

  • Key shows visual arc gauge
  • Arc fills based on percentage (0% = empty, 100% = full)
  • Percentage text is readable
  • Label identifies the metric

Phase 5: Color Threshold System (Day 6)

Goal: Implement dynamic color coding

Tasks:

  1. Define threshold interface and defaults
  2. Implement getColorForValue() function
  3. Add HSL interpolation for smooth gradients
  4. Connect color calculation to gauge rendering
  5. Test with various percentage values

Verification:

  • 25% CPU shows green
  • 65% CPU shows yellow-orange
  • 90% CPU shows red
  • Color transitions smoothly as values change

Phase 6: Property Inspector Configuration (Day 7)

Goal: Allow user customization of settings

Tasks:

  1. Create HTML Property Inspector for each action
  2. Add polling interval input (number field)
  3. Add threshold sliders (warning, critical)
  4. Implement validation (warning < critical)
  5. Send settings to plugin backend
  6. Restart polling when settings change

Verification:

  • Opening PI shows current settings
  • Changing interval updates polling frequency
  • Changing thresholds updates color coding
  • Invalid settings show validation error

Questions to Guide Your Design

Work through these questions before coding. They will surface the architectural decisions you need to make:

Manifest and Architecture

  1. How do you define multiple actions in manifest.json? What makes each action unique?
  2. Should CPU, RAM, and Disk share a base class, or should they be completely independent?
  3. How does Stream Deck route events (willAppear, keyDown) to the correct action instance?
  4. Can you have multiple instances of the same action (e.g., two CPU keys showing different cores)?

Polling and Data Flow

  1. Where should the polling loop live—in each action instance, or in a shared service?
  2. What polling interval balances real-time feel against CPU overhead? (Hint: 1000-2000ms is typical)
  3. How do you stop polling when an action is removed from the deck?
  4. What happens if systeminformation throws an error during a poll cycle?

Visual Rendering

  1. How do you render a CPU percentage meter on a 72x72 canvas?
  2. Should you use arc gauges, bar graphs, or just large numbers? (User preference via Property Inspector?)
  3. How do you calculate the color for 67% usage? (Linear interpolation between thresholds)
  4. How do you handle high CPU states—just color change, or also animation/flashing?

Property Inspector

  1. What settings should be configurable per-action? (Polling interval, thresholds, display style)
  2. How do global settings differ from action-specific settings?
  3. How do you validate that warning threshold < critical threshold?

Thinking Exercise

Before coding, trace through this mental simulation of how polling and rendering interact:

MENTAL MODEL: Polling + Rendering Pipeline
==========================================

T=0ms: Plugin initializes
       +-- Create CpuAction, RamAction, DiskAction instances
       +-- Each action calls startPolling() in onWillAppear
       +-- Each sets up: setInterval(pollAndRender, 2000)

T=2000ms: First poll cycle fires
       +-- CpuAction.pollAndRender():
       |   +-- const cpu = await si.currentLoad()        // ~50ms
       |   +-- const percentage = cpu.currentLoad        // e.g., 67
       |   +-- const color = calculateColor(67, thresholds)  // yellow
       |   +-- const image = renderGauge(67, color)      // Canvas magic
       |   +-- this.setImage(image)                      // Send to Stream Deck
       |
       +-- RamAction.pollAndRender():
       |   +-- const mem = await si.mem()                // ~30ms
       |   +-- const percentage = (mem.used / mem.total) * 100
       |   +-- ... render and send
       |
       +-- DiskAction.pollAndRender():
           +-- const disk = await si.fsSize()            // ~100ms
           +-- ... render and send

T=4000ms: Second poll cycle fires
       +-- Same flow, but values may have changed

QUESTION: What happens if si.currentLoad() takes 500ms?
ANSWER: The interval still fires every 2000ms, but you might get overlapping
        calls if you are not careful. Consider using a lock or Promise chain.

QUESTION: What happens when user removes the CPU key from their deck?
ANSWER: onWillDisappear fires -> clearInterval(this.pollTimer) -> cleanup

Walk through this diagram mentally:

  1. What would happen if you forgot to clear the interval on willDisappear? (Memory leak, zombie updates)
  2. What if two CPU keys are added? (Each gets its own polling loop—is that wasteful?)
  3. How would you optimize to share one poll across all actions? (Event emitter pattern)

Testing Strategy

Unit Tests

Testing Color Calculation

describe('getColorForValue', () => {
    const thresholds = { warning: 50, critical: 80 };

    test('returns green below warning', () => {
        expect(getColorForValue(25, thresholds)).toBe('#4ade80');
    });

    test('returns red above critical', () => {
        expect(getColorForValue(90, thresholds)).toBe('#ef4444');
    });

    test('interpolates between warning and critical', () => {
        const color = getColorForValue(65, thresholds);
        expect(color).toMatch(/^hsl\(/); // HSL interpolated
    });
});

Testing Polling Lifecycle

describe('CpuAction lifecycle', () => {
    test('starts polling on willAppear', () => {
        const action = new CpuAction();
        action.onWillAppear(mockEvent);
        expect(action['pollTimer']).toBeDefined();
    });

    test('stops polling on willDisappear', () => {
        const action = new CpuAction();
        action.onWillAppear(mockEvent);
        action.onWillDisappear(mockEvent);
        expect(action['pollTimer']).toBeUndefined();
    });
});

Mocking systeminformation

For reliable tests, mock the system information library:

jest.mock('systeminformation', () => ({
    currentLoad: jest.fn().mockResolvedValue({
        currentLoad: 45.5,
        currentLoadUser: 30.0,
        currentLoadSystem: 15.5,
    }),
    mem: jest.fn().mockResolvedValue({
        total: 17179869184,
        used: 10737418240,
        free: 6442450944,
    }),
    fsSize: jest.fn().mockResolvedValue([{
        fs: '/dev/disk1s1',
        size: 499963174912,
        used: 299977904947,
        use: 60.0,
        mount: '/',
    }]),
}));

Integration Tests

  1. Full pipeline test: Mock systeminformation, verify correct image is generated
  2. Settings change test: Update thresholds, verify color changes
  3. Error handling test: Mock systeminformation to throw, verify graceful degradation

Manual Testing Checklist

  • Add CPU key, verify it displays and updates
  • Add RAM key, verify it displays and updates
  • Add Disk key, verify it displays and updates
  • Remove a key, verify no console errors
  • Change polling interval in PI, verify new frequency
  • Change thresholds, verify color changes
  • Run a CPU-intensive task, verify CPU key turns red
  • Fill disk with large files, verify disk key warns

Common Pitfalls and Debugging Tips

Memory Leaks from Orphaned Timers

Symptom: Plugin memory grows over time, eventually crashes

Cause: setInterval not cleared when key removed

Detection:

// Add logging to your cleanup
private stopPolling(): void {
    console.log(`[${this.context}] Stopping polling, timer:`, !!this.pollTimer);
    if (this.pollTimer) {
        clearInterval(this.pollTimer);
        this.pollTimer = undefined;
    }
}

Fix: Always clear timers in onWillDisappear


Image Not Updating

Symptom: Key shows static image, never changes

Possible Causes:

  1. setImage() not being called
  2. Canvas data URL malformed
  3. Wrong context passed to setImage()

Debugging:

private async updateKey(percentage: number): Promise<void> {
    const image = this.renderGauge(percentage);
    console.log('Image data URL length:', image.length);
    console.log('First 100 chars:', image.substring(0, 100));
    await this.setImage(image);
}

systeminformation Throws Error

Symptom: Plugin stops working, console shows errors

Cause: systeminformation can fail for various reasons (permissions, platform issues)

Solution:

private async pollAndRender(): Promise<void> {
    try {
        const cpu = await si.currentLoad();
        await this.renderGauge(cpu.currentLoad);
    } catch (error) {
        console.error('Failed to get CPU info:', error);
        // Show error state on key
        await this.renderError('N/A');
    }
}

Color Looks Wrong

Symptom: Colors do not match expected thresholds

Possible Causes:

  1. Threshold comparison logic inverted
  2. Color interpolation math error
  3. Settings not loaded correctly

Debugging:

function getColorForValue(value: number, thresholds: Thresholds): string {
    console.log(`Calculating color for ${value}, thresholds:`, thresholds);

    if (value < thresholds.warning) {
        console.log('Below warning -> green');
        return '#4ade80';
    }
    // ... etc
}

Polling Too Fast / Too Slow

Symptom: Updates feel laggy or CPU usage is high

Diagnosis:

private async pollAndRender(): Promise<void> {
    const startTime = Date.now();

    const cpu = await si.currentLoad();

    const elapsed = Date.now() - startTime;
    console.log(`Poll took ${elapsed}ms`);

    await this.renderGauge(cpu.currentLoad);
}

Guidelines:

  • Polling interval should be 10-20x longer than poll duration
  • If poll takes 100ms, interval should be at least 1000ms
  • For real-time feel, 1000-2000ms interval works well

The Interview Questions They Will Ask

These are real questions you might face when discussing Stream Deck plugin development or general monitoring system design:

Plugin Architecture

Q: “How do you handle multiple actions in one Stream Deck plugin, and how does event routing work?”

Expected answer: Explain manifest action definitions with unique UUIDs, how the SDK routes events based on action context, and the relationship between action classes and instances.

Q: “What is the difference between global settings and action settings in Stream Deck plugins?”

Expected answer: Global settings persist across all actions (set once, affect everything). Action settings are per-instance (each key can have different thresholds). Global settings use getGlobalSettings()/setGlobalSettings(), action settings use the settings payload in events.

System Design

Q: “How do you optimize polling for performance when monitoring multiple metrics?”

Expected answer: Discuss shared polling service vs. per-action polling, batching systeminformation calls, caching recent values, debouncing render updates, and adjusting intervals based on visibility state.

Q: “Your CPU monitor is causing 5% CPU usage itself. How do you debug and optimize this?”

Expected answer: Profile with Chrome DevTools (Node.js inspector), check polling frequency, batch systeminformation calls, reduce canvas rendering complexity, use simpler visualizations, consider lazy polling when app is in background.

Error Handling

Q: “What happens if the system information library fails during a poll? How do you handle it gracefully?”

Expected answer: Wrap async calls in try/catch, display “N/A” or last known value on error, implement exponential backoff for retries, log errors for debugging, potentially show error icon on key.

Visual Design

Q: “How do you decide what color to show for 67% CPU usage if your thresholds are 50% (yellow) and 80% (red)?”

Expected answer: Linear interpolation between yellow and red using HSL color space (not RGB), calculating t = (67 - 50) / (80 - 50) = 0.57, then interpolating hue/saturation/lightness. Discuss why HSL produces better gradients than RGB.


Hints in Layers

Reveal these progressively as you work through the project:

Hint 1: Getting System Information

The systeminformation library is your friend. Install it and explore the API:

import si from 'systeminformation';

// CPU usage (percentage 0-100)
const cpu = await si.currentLoad();
console.log(cpu.currentLoad); // e.g., 45.2

// Memory (bytes)
const mem = await si.mem();
console.log((mem.used / mem.total) * 100); // percentage used

// Disk (array of filesystems)
const disks = await si.fsSize();
console.log(disks[0].use); // percentage used for first disk
Hint 2: Polling with setInterval

Set up a polling loop that cleans up properly:

class CpuAction extends SingletonAction {
    private pollTimer?: NodeJS.Timeout;

    override onWillAppear(): void {
        this.startPolling();
    }

    override onWillDisappear(): void {
        this.stopPolling();
    }

    private startPolling(): void {
        // Poll immediately, then every 2 seconds
        this.pollAndRender();
        this.pollTimer = setInterval(() => this.pollAndRender(), 2000);
    }

    private stopPolling(): void {
        if (this.pollTimer) {
            clearInterval(this.pollTimer);
            this.pollTimer = undefined;
        }
    }

    private async pollAndRender(): Promise<void> {
        try {
            const cpu = await si.currentLoad();
            await this.renderGauge(cpu.currentLoad);
        } catch (error) {
            console.error('Failed to get CPU info:', error);
            await this.renderError();
        }
    }
}
Hint 3: Canvas Gauge Rendering

Draw an arc gauge that fills based on percentage:

import { createCanvas } from 'canvas';

function renderGauge(percentage: number, thresholds: Thresholds): string {
    const size = 144; // 144x144 for @2x retina
    const canvas = createCanvas(size, size);
    const ctx = canvas.getContext('2d');

    // Background
    ctx.fillStyle = '#1a1a2e';
    ctx.fillRect(0, 0, size, size);

    // Arc settings
    const centerX = size / 2;
    const centerY = size / 2;
    const radius = size * 0.35;
    const startAngle = 0.75 * Math.PI;  // 7 o'clock
    const endAngle = 2.25 * Math.PI;    // 5 o'clock
    const arcLength = endAngle - startAngle;

    // Background arc (gray)
    ctx.beginPath();
    ctx.arc(centerX, centerY, radius, startAngle, endAngle);
    ctx.strokeStyle = '#333';
    ctx.lineWidth = 12;
    ctx.lineCap = 'round';
    ctx.stroke();

    // Value arc (colored by threshold)
    const valueAngle = startAngle + (percentage / 100) * arcLength;
    ctx.beginPath();
    ctx.arc(centerX, centerY, radius, startAngle, valueAngle);
    ctx.strokeStyle = getColorForValue(percentage, thresholds);
    ctx.stroke();

    // Percentage text
    ctx.fillStyle = '#fff';
    ctx.font = 'bold 32px Arial';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(`${Math.round(percentage)}%`, centerX, centerY);

    return canvas.toDataURL();  // Returns base64 data URL for setImage()
}
Hint 4: Color Gradients Based on Thresholds

Calculate colors that smoothly transition between thresholds:

interface Thresholds {
    warning: number;   // e.g., 50
    critical: number;  // e.g., 80
}

function getColorForValue(value: number, thresholds: Thresholds): string {
    // Below warning: green
    if (value < thresholds.warning) {
        return '#4ade80'; // green-400
    }

    // Between warning and critical: interpolate yellow to orange
    if (value < thresholds.critical) {
        const t = (value - thresholds.warning) / (thresholds.critical - thresholds.warning);
        return interpolateColor('#facc15', '#f97316', t); // yellow to orange
    }

    // Above critical: red
    return '#ef4444'; // red-500
}

function interpolateColor(color1: string, color2: string, t: number): string {
    // Parse hex to RGB
    const r1 = parseInt(color1.slice(1, 3), 16);
    const g1 = parseInt(color1.slice(3, 5), 16);
    const b1 = parseInt(color1.slice(5, 7), 16);

    const r2 = parseInt(color2.slice(1, 3), 16);
    const g2 = parseInt(color2.slice(3, 5), 16);
    const b2 = parseInt(color2.slice(5, 7), 16);

    // Interpolate
    const r = Math.round(r1 + (r2 - r1) * t);
    const g = Math.round(g1 + (g2 - g1) * t);
    const b = Math.round(b1 + (b2 - b1) * t);

    return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
Hint 5: Shared Polling Service

For efficiency with multiple action instances, create a shared service:

import { EventEmitter } from 'events';
import si from 'systeminformation';

interface SystemMetrics {
    cpu: number;
    mem: number;
    disk: number;
    timestamp: number;
}

class MetricsService extends EventEmitter {
    private static instance: MetricsService;
    private pollTimer?: NodeJS.Timeout;
    private subscriberCount = 0;
    private interval = 2000;

    static getInstance(): MetricsService {
        if (!MetricsService.instance) {
            MetricsService.instance = new MetricsService();
        }
        return MetricsService.instance;
    }

    subscribe(callback: (metrics: SystemMetrics) => void): () => void {
        this.on('metrics', callback);
        this.subscriberCount++;

        if (this.subscriberCount === 1) {
            this.startPolling();
        }

        // Return unsubscribe function
        return () => {
            this.off('metrics', callback);
            this.subscriberCount--;
            if (this.subscriberCount === 0) {
                this.stopPolling();
            }
        };
    }

    private startPolling(): void {
        this.poll();
        this.pollTimer = setInterval(() => this.poll(), this.interval);
    }

    private stopPolling(): void {
        if (this.pollTimer) {
            clearInterval(this.pollTimer);
            this.pollTimer = undefined;
        }
    }

    private async poll(): Promise<void> {
        try {
            const [cpu, mem, disk] = await Promise.all([
                si.currentLoad(),
                si.mem(),
                si.fsSize(),
            ]);

            const metrics: SystemMetrics = {
                cpu: cpu.currentLoad,
                mem: (mem.used / mem.total) * 100,
                disk: disk[0]?.use ?? 0,
                timestamp: Date.now(),
            };

            this.emit('metrics', metrics);
        } catch (error) {
            console.error('Failed to poll metrics:', error);
        }
    }
}

// Usage in action class:
class CpuAction extends SingletonAction {
    private unsubscribe?: () => void;

    override onWillAppear(): void {
        this.unsubscribe = MetricsService.getInstance().subscribe((metrics) => {
            this.renderGauge(metrics.cpu);
        });
    }

    override onWillDisappear(): void {
        this.unsubscribe?.();
    }
}
Hint 6: Property Inspector Communication

Handle settings from Property Inspector:

// In your action class
interface ActionSettings {
    pollingInterval: number;
    warningThreshold: number;
    criticalThreshold: number;
    displayStyle: 'gauge' | 'percentage' | 'bar';
}

class CpuAction extends SingletonAction {
    private settings: ActionSettings = {
        pollingInterval: 2000,
        warningThreshold: 50,
        criticalThreshold: 80,
        displayStyle: 'gauge',
    };

    override onDidReceiveSettings(ev: DidReceiveSettingsEvent): void {
        const newSettings = ev.payload.settings as Partial<ActionSettings>;

        // Merge with defaults
        this.settings = { ...this.settings, ...newSettings };

        // Restart polling if interval changed
        this.stopPolling();
        this.startPolling();
    }

    private getPollingInterval(): number {
        return this.settings.pollingInterval;
    }

    private getThresholds(): Thresholds {
        return {
            warning: this.settings.warningThreshold,
            critical: this.settings.criticalThreshold,
        };
    }
}
<!-- In pi/cpu.html -->
<script>
const $SD = window.$SD;

$SD.on('connected', (ev) => {
    // Load current settings
    const settings = ev.actionInfo.payload.settings;
    document.getElementById('interval').value = settings.pollingInterval || 2000;
    document.getElementById('warning').value = settings.warningThreshold || 50;
    document.getElementById('critical').value = settings.criticalThreshold || 80;
});

function saveSettings() {
    const warning = parseInt(document.getElementById('warning').value);
    const critical = parseInt(document.getElementById('critical').value);

    // Validate
    if (warning >= critical) {
        alert('Warning threshold must be less than critical threshold');
        return;
    }

    $SD.api.setSettings({
        pollingInterval: parseInt(document.getElementById('interval').value),
        warningThreshold: warning,
        criticalThreshold: critical,
    });
}
</script>

Extensions and Challenges

Beginner Extensions

  • Add network monitor action showing upload/download speeds
  • Display actual values (e.g., “8.2 GB / 16 GB”) instead of just percentages
  • Add system uptime display action
  • Create per-core CPU breakdown (one key per core)

Intermediate Extensions

  • GPU monitoring (using si.graphics())
  • Historical sparkline graphs (tiny line chart showing last N values)
  • Temperature monitoring for CPU/GPU
  • Process list action (show top CPU/memory consumers)
  • Alert sounds when thresholds exceeded

Advanced Extensions

  • WebSocket dashboard that mirrors Stream Deck to browser
  • Remote monitoring (monitor different machine over network)
  • Custom gauge designs (radial, vertical bar, horizontal bar)
  • Animated threshold transitions (smooth color fades)
  • Machine learning anomaly detection (alert on unusual patterns)

Real-World Connections

Monitoring Dashboards (Grafana, DataDog)

Everything you learn here applies to professional monitoring tools:

System Monitor Plugin Grafana/DataDog
systeminformation calls Prometheus metrics collectors
Polling interval Scrape interval
Color thresholds Alert rules
Canvas gauge rendering D3.js / Canvas visualizations
Property Inspector Dashboard configuration panels

DevOps and SRE

System monitoring is fundamental to DevOps:

  • Observability: CPU/RAM/Disk are the “three pillars” alongside logs and traces
  • Alerting: Your color thresholds are simple alert rules
  • Capacity Planning: Disk monitoring warns before outages
  • Incident Response: Real-time CPU spikes indicate problems

Desktop Application Development

The patterns extend beyond Stream Deck:

  • Electron apps: Same Node.js + Canvas + system APIs
  • VS Code extensions: Similar manifest + activation events
  • Browser extensions: Manifest + background scripts + popups
  • Electron system trays: Polling + icon updates

Books That Will Help

Topic Book Chapter/Section Why It Helps
Multi-action architecture Node.js Design Patterns Ch. 8: Structural Patterns Organizing code for multiple action classes sharing common functionality
Polling and timers Node.js Design Patterns Ch. 3: Callbacks and Events Understanding event loop behavior with setInterval
Async data fetching Node.js Design Patterns Ch. 5: Promises and Async/Await Clean async code for systeminformation calls
Canvas graphics HTML5 Canvas by Steve Fulton Ch. 2, 4, 5: Drawing, Images, Animation Rendering gauges, gradients, and meters
Color theory for UI Refactoring UI “Color” section Choosing effective threshold colors
TypeScript patterns Effective TypeScript Item 30: Type inference Typing your action settings and state
Error handling Effective TypeScript Item 46: unknown vs any Gracefully handling polling failures
Resource cleanup Node.js Design Patterns Ch. 3: Callbacks and Events Properly clearing intervals on cleanup
Event-driven architecture Node.js Design Patterns Ch. 3: Callbacks and Events Building the shared metrics service
Observer pattern Design Patterns (GoF) Observer pattern Understanding subscribe/unsubscribe mechanics

Self-Assessment Checklist

Conceptual Understanding

  • I can explain how Stream Deck routes events to different action classes
  • I understand why setInterval callbacks can overlap and how to prevent it
  • I can describe the tradeoffs between independent polling and shared polling services
  • I know why HSL produces better color gradients than RGB
  • I can explain the importance of cleaning up timers in onWillDisappear
  • I understand how Property Inspector settings flow to the plugin backend

Practical Skills

  • I can define multiple actions in a single manifest.json
  • I can use systeminformation to query CPU, memory, and disk metrics
  • I can render a visual gauge using the Canvas API
  • I can implement color interpolation based on threshold values
  • I can create a Property Inspector that saves and loads settings
  • I can debug polling issues by inspecting timer state

Teaching Test

Can you explain to someone else:

  • Why monitoring your CPU usage with a plugin that uses too much CPU defeats the purpose?
  • How the context identifier distinguishes between multiple instances of the same action?
  • Why you should not use RGB interpolation for smooth color gradients?
  • What happens if you forget to call clearInterval when a key is removed?

Production Readiness

  • My plugin handles systeminformation errors gracefully
  • No memory leaks from orphaned timers
  • Settings validation prevents invalid configurations
  • Console logging is appropriate (not too verbose, but helpful for debugging)
  • I have tested with multiple instances of the same action

When you complete this project, you will have mastered the fundamental patterns of real-time monitoring applications. The same architecture applies to server dashboards, IoT displays, trading terminals, and any application that needs to continuously poll data and present it visually. Your Stream Deck will become a genuinely useful productivity tool that you built yourself.