Field Note · Infrastructure & Self-Hosting

Monitoring Intel Arc Pro B60 GPUs With Cockpit

A small exporter and Cockpit package that show Intel Arc Pro B60 utilization, memory, power and temperature, with its single-host limits.

Published
Status
Prototype

I built this for a Linux host with an Intel Arc Pro B60 that is already managed through Cockpit, the browser-based server console. Cockpit shows CPU, memory, disk and network out of the box, but it has no idea the GPU exists. I wanted GPU utilization, GPU memory, power and temperature on the same Overview and Metrics pages I already look at, without standing up a separate monitoring stack for one card.

So I wrote cockpit-intel-gpu-overview: a small Python exporter that polls Intel’s xpu-smi tool, plus a Cockpit package that renders the result. This post walks through where the numbers come from, how the pieces fit, what the installer changes on the host, and what it does not do.

Status: a personal, single-host tool. The README says it was written for one Arc Pro B60 on one apt-based Linux system, and the code hard-codes that host’s device paths. It has no automated tests. Treat it as a working example, not a packaged product. Intel and Arc are trademarks of Intel Corporation; this project is not affiliated with or endorsed by Intel.

Problem

Why a Cockpit plugin

The obvious alternative was Intel’s full XPU Manager daemon, which offers REST and Prometheus-style export, feeding a Prometheus and Grafana setup. That is the right answer for a fleet. For one box it is a lot of moving parts, and I was not looking for long-term retention or alerting. I wanted a glance: is the GPU busy, how much memory is in use, how hot it is running.

Cockpit was already installed and already the place I check a host. Cockpit packages are plain HTML and JavaScript served by the Cockpit web service, and the cockpit.file() API lets a page watch a file on the host and get callbacks when it changes. That makes a very simple contract possible: something privileged writes a JSON file, and the browser reads it.

There is also a packaging constraint. The README notes that Intel’s full xpumanager package conflicts with xpu-smi, so you install one or the other. This host uses xpu-smi from Intel’s graphics package feed, and the exporter is built around it.

Solution

The repository has three parts:

  • A root-owned systemd service, cockpit-intel-gpu-exporter, that polls xpu-smi every two seconds and writes /run/cockpit-intel-gpu/metrics.json plus a per-minute history.json.
  • A Cockpit package named intel-gpu that adds a “GPU” page to the Cockpit menu, and two scripts injected into Cockpit’s own Overview and Metrics pages to add GPU cards there.
  • The third-party Cockpit Sensors package with lm-sensors, which the installer adds for host temperature, fan and voltage readings from the kernel hwmon stack. The exporter does not read from it; it sits alongside.

Architecture

Diagram: xpu-smi and sysfs feed a root-run Python exporter, which writes metrics.json and history.json under /run; the Cockpit GPU page, Overview card and Metrics card read those files in the browser.
Data flow, drawn from the source at commit cc15287. Only the exporter runs as root; the browser side only reads JSON.

The split is the main design decision. Everything that needs privilege lives in one short Python file under systemd. Everything in the browser is unprivileged and read-only: it never runs xpu-smi, never calls a privileged Cockpit channel, and never writes to the host.

Implementation

Where the numbers come from

At startup the exporter calls xpu-smi discovery -d 0 -j once to get the device name, PCI address, DRM device and physical memory size. If the JSON form fails, it falls back to regex-parsing the plain-text table. Then, on every poll, it runs xpu-smi dump -d 0 -m <metric list> -i 1 -n 1, which prints a CSV header and a single sample row.

The parser does not rely on column positions. It finds the line starting with Timestamp,, reads it with csv.DictReader, and looks fields up by their header text, accepting more than one spelling for the engine columns:

# src/exporter/cockpit-intel-gpu-exporter.py
    return {
        "timestamp": row.get("Timestamp", "").strip(),
        "device_id": row.get("DeviceId", "").strip(),
        "utilization_percent": first("Average % utilization of all GPU Engines"),
        "power_watts": first("GPU Power (W)"),
        "frequency_mhz": first("GPU Frequency (MHz)"),
        "temperature_c": first("GPU Core Temperature (Celsius Degree)"),
        "memory_util_percent": first("GPU Memory Utilization (%)"),
        "memory_used_mib": first("GPU Memory Used (MiB)"),
        "compute_percent": first("Compute Engine 0 (%)", "Compute Engine (%)"),
        ...

Any value of N/A, an empty string, or something that will not parse as a float becomes null in the JSON, and the UI shows -- for it. That matters, because not every field is populated every time.

sysfs fallbacks

Two fields have a second source. If xpu-smi returns no overall utilization, the exporter computes it from the kernel’s GT idle-residency counters. It reads tile0/gt0 and tile0/gt1 gtidle/idle_residency_ms under /sys/class/drm/card0/device immediately before and after the one-second xpu-smi dump, and treats the time not spent idle as busy:

# src/exporter/cockpit-intel-gpu-exporter.py
    busy = []
    for old_idle, new_idle in zip(before["idle_ms"], after["idle_ms"]):
        idle_delta = max(0, new_idle - old_idle)
        busy.append(100 - min(100, (idle_delta / elapsed_ms) * 100))
    if not busy:
        return None
    return max(0, min(100, max(busy)))

It reports the busier of the two GTs rather than an average. That is a reasonable “is anything working” signal, but it is not the same quantity as Intel’s “average utilization of all engines”, so the two sources are not directly comparable.

If xpu-smi returns no core temperature, the exporter reads temp2_input or temp3_input from hwmon/hwmon5 under the same device and divides by 1000. The hwmon index is hard-coded. hwmon numbering is assigned at boot and can differ between machines, so this path is specific to the host it was written on.

What is and is not on the page

The fields the exporter knows how to extract are: overall GPU utilization, power in watts, GPU frequency, core temperature, memory used and memory utilization, total memory from discovery, and per-engine utilization for compute, render, copy, decoder and encoder (engine 0 of each, or the unsuffixed column).

Things it does not collect: fan speed, memory temperature, PCIe throughput, ECC or RAS error counters, per-process GPU usage, throttle reasons, and anything about a second GPU. Some of these may be available from xpu-smi on this card; the exporter simply does not ask for or parse them.

I cannot give a definitive list of which fields the B60 actually populates through xpu-smi, because I have no card to check against for this write-up and the repository does not include captured output. What the code tells you is indirect: the sysfs fallbacks exist specifically for utilization and temperature, and the README says the exporter runs as root because, on this host, those two metrics require access to the Intel MEI device. Read that as “these fields needed extra work here”, not as a statement about every B60 or driver version.

How the UI reads the data

The GPU page is a static index.html with placeholders and a short gpu.js. On load it asks Cockpit for the current user, then watches the system metrics file. If that file is missing on the first callback, it falls back to the per-user location written by the alternative user-level installer:

# src/cockpit/gpu.js
  function watchMetrics(paths, index) {
    if (file) {
      file.close();
    }
    file = cockpit.file(paths[index], { syntax: JSON });
    let initial = true;
    file.watch((content, tag, error) => {
      if ((error || content === null) && initial && index + 1 < paths.length) {
        watchMetrics(paths, index + 1);
        return;
      }
      initial = false;
      ...
      render(content);
    });
  }

Because the exporter replaces the file atomically (write to a temp file in the same directory, chmod 0644, then os.replace), the watcher never sees a half-written JSON document.

The Overview and Metrics integrations work differently. Cockpit’s own pages are React applications, and there is no extension point for adding a card. The installer instead adds one <link> and one <script> tag to /usr/share/cockpit/systemd/index.html and /usr/share/cockpit/metrics/index.html. The injected scripts use a MutationObserver on the document; whenever Cockpit re-renders, they find the card gallery by CSS class (.ct-system-overview or .pf-v5-l-gallery on Overview, .current-metrics on Metrics) and append a card titled “Intel Arc Pro B60” if it is not there. Renders are coalesced through requestAnimationFrame.

On the Metrics page, the script also adds “GPU” and “GPU memory” columns to Cockpit’s per-minute history grid. It parses each hour block’s start time from its element id (metrics-hour-<ms>), looks up the matching minute in history.json, and sets a bar height. The exporter keeps one history entry per minute (later samples in the same minute overwrite earlier ones), skips failed polls, and trims to 2,880 minutes, which is 48 hours.

This approach is effective and fragile in equal measure. It depends on PatternFly v5 class names and on Cockpit’s internal DOM structure, neither of which is a public API.

Installation

On the target host, from the repository root:

# README.md
sudo ./scripts/install.sh

Then refresh the Cockpit tab. It is worth knowing exactly what that script does before running it as root:

  • Warns, but continues, if xpu-smi is not on the PATH.
  • On apt hosts, runs apt-get update and installs ca-certificates, lm-sensors, wget and xz-utils; on non-apt hosts it exits unless sensors is already present. It then runs sensors-detect --auto and tries to load the k10temp, drivetemp and nct6775 kernel modules.
  • If /usr/share/cockpit/sensors does not exist, downloads the latest Cockpit Sensors release tarball from GitHub and installs it there, with a marker file so the uninstaller knows it owns that directory.
  • Installs the exporter to /usr/local/libexec/, the unit to /etc/systemd/system/, and the intel-gpu package to /usr/local/share/cockpit/intel-gpu/.
  • Backs up Cockpit’s Overview and Metrics index.html files (once) with a .cockpit-intel-gpu.bak suffix, copies the two injected scripts and stylesheets next to them, and adds the tags.
  • Enables and restarts the exporter, and restarts cockpit.socket if it is running.

sudo ./scripts/uninstall.sh reverses this: it stops and removes the service, removes the package and injected files, removes Cockpit Sensors only if the marker file says this installer put it there, and restores the backed-up index.html files (or strips the tags if no backup exists). It does not remove lm-sensors or the other apt packages.

There is also scripts/install-user.sh, which runs without sudo. It copies Cockpit’s systemd and metrics packages into ~/.local/share/cockpit/ and patches those copies, installs the exporter as a systemd user service writing to ~/.cache/cockpit-intel-gpu/, and strips the manifest’s path-exists condition. On a host where GPU metrics need root, the user-level exporter will likely produce partial or failed samples, so I treat it as a development path.

Permissions and security

Running a collector as root is the part I thought about most. The systemd unit applies the standard hardening switches so that the root process has as little room as practical:

# systemd/cockpit-intel-gpu-exporter.service
RuntimeDirectory=cockpit-intel-gpu
RuntimeDirectoryMode=0755
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/run/cockpit-intel-gpu

With ProtectSystem=strict the file system is read-only to the service except for its own runtime directory. The exporter runs fixed argument lists with no shell and takes no input from the browser, so there is no path from a Cockpit page to a command on the host. The UI injects HTML only from constant strings and writes telemetry values with textContent.

Some trade-offs are worth stating plainly:

  • The metrics and history files are mode 0644 in a 0755 directory, so any local user can read GPU telemetry. That is a deliberate choice to let Cockpit read them as the logged-in user; it is not suitable where GPU activity itself is sensitive.
  • The service still runs as root. The hardening reduces what it can change, not what it can read. A narrower option would be a dedicated user with group access to the specific device nodes; I have not tested that.
  • The installer fetches the “latest” Cockpit Sensors release over HTTPS with no pinned version or checksum. That is a supply-chain dependency on a third-party repository at install time. Pinning a release and verifying a hash would be the stronger choice.
  • Editing files under /usr/share/cockpit/ means a Cockpit package upgrade can overwrite index.html and silently drop the Overview and Metrics cards. The standalone GPU page lives in /usr/local/share/cockpit/ and is not affected. Re-running the installer restores the tags; the backup is only created once, so it reflects the version present at first install.

Evidence

The plugin's GPU page rendered with synthetic data: GPU 37%, GPU memory 3.0 of 24.0 GiB, power, temperature, frequency, and per-engine percentages.
The repository’s own GPU page HTML, CSS and JavaScript, rendered locally in a headless browser with a stub in place of Cockpit’s cockpit.js feeding one synthetic payload. Every number here is made up; this is not a capture from real hardware.

The repository has no test suite and no CI configuration. For this article I did two local checks at commit cc15287 on 2026-10-21, without any Intel hardware:

  • I imported the exporter module and fed parse_dump a synthetic CSV I wrote using the column names the parser looks for. It produced the expected values, turned N/A into null, and raised ValueError on header-only output. The idle-residency function returned 75% for a synthetic case where the busier GT was idle 250 ms out of 1,000. Two history writes in the same minute produced one entry, and a failed payload did not add one.
  • I rendered the GPU page in headless Chromium with a stub standing in for Cockpit’s JavaScript API, which produced the screenshot above.

Neither check exercises xpu-smi, real sysfs files, or Cockpit itself. The repository’s history and README describe it running on the author’s host, but I do not have logs or captures from that host to cite, so I am not claiming hardware validation here.

Limitations

This is a single-host tool that works for the machine it was written on. Before using it elsewhere, expect to change code:

  • One GPU only. Device id 0, /sys/class/drm/card0 and hwmon5 are constants. On a system with an integrated GPU or a second card, card0 may be the wrong device and the fallbacks will read the wrong data or nothing.
  • Hard-coded name. The Overview and Metrics cards are titled “Intel Arc Pro B60” regardless of what discovery reports.
  • Depends on Cockpit internals. The injected scripts rely on PatternFly v5 class names and Cockpit’s page structure. A Cockpit update can break them or remove them.
  • apt only. The system installer targets apt-based distributions; other distributions need manual steps.
  • Short-lived history. History is two metrics (utilization and memory) at one-minute resolution for 48 hours, stored under /run, so it is lost on reboot. There is no alerting.
  • Discovery runs once. Device details are read at service start; if xpu-smi was unavailable then, they stay empty until the service restarts.
  • README drift. The README says the Overview script inserts GPU rows into the Usage card. The current script removes those legacy rows and adds a standalone card instead.
  • No tests, no CI, no packaging. There is no .deb or RPM, and no test suite.

If I extend it, the first changes would be discovering the DRM card and hwmon index from the PCI address rather than hard-coding them, taking the card title from discovery, pinning the Cockpit Sensors download, and adding fixture-based tests for the parser using captured xpu-smi output.

On the services side, this kind of small, host-level visibility work falls under website hosting and infrastructure. If you are planning on-premises GPU hardware for model workloads, the AI-driven analytics service page describes that side of the work.

Resources

Related CompleteTech services

Discuss a similar project
Written by

Timothy Gregg · Founder, CompleteTech

Enterprise IT operations and ITSM implementation on ManageEngine and Microsoft platforms, now focused on AI automation and systems engineering. Presented at the 2023 ManageEngine user conference.