Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions codecarbon/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,15 @@ def monitor(
str,
typer.Option(help="Log level (critical, error, warning, info, debug)"),
] = "error",
ui: Annotated[
bool,
typer.Option(help="Serve a live dashboard in your browser"),
] = False,
ui_port: Annotated[int, typer.Option(help="Port of the live dashboard")] = 8050,
ui_host: Annotated[
str,
typer.Option(help="Host to bind the live dashboard to"),
] = "127.0.0.1",
):
"""Monitor your machine's carbon emissions."""

Expand Down Expand Up @@ -424,6 +433,20 @@ def monitor(

tracker_args = {**tracker_args, "save_to_api": api}

if ui:
from codecarbon.viz.live import LiveDashboardOutput

live_output = LiveDashboardOutput(port=ui_port, host=ui_host)
tracker_args.setdefault("output_handlers", []).append(live_output)
if live_output.is_serving:
print(f"Live dashboard: {live_output.url}")
else:
print(
f"WARNING: could not start the live dashboard on {ui_host}:{ui_port}, "
"monitoring continues without it.",
file=sys.stderr,
)

from codecarbon.emissions_tracker import EmissionsTracker, OfflineEmissionsTracker

# If extra args are provided (e.g. `codecarbon monitor -- my_script.py`), delegate to `run_and_monitor`
Expand Down
21 changes: 21 additions & 0 deletions codecarbon/emissions_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,27 @@ def _measure_power_and_energy(self) -> None:
self._do_measurements()
self._last_measured_time = time.perf_counter()
self._measure_occurrence += 1

# Handlers displaying data locally opt in to every measure by defining
# `on_measure`. They get the total only: computing the delta here would
# consume it for the periodic call below. The power fields of
# EmissionsData are averages since `start()`, which is not what a live
# view wants, so they are replaced by the last measured power.
on_measure_handlers = [
handler.on_measure
for handler in self._output_handlers
if hasattr(handler, "on_measure")
]
if on_measure_handlers:
live = dataclasses.replace(
self._prepare_emissions_data(),
cpu_power=self._cpu_power.W,
gpu_power=self._gpu_power.W,
ram_power=self._ram_power.W,
)
for on_measure in on_measure_handlers:
on_measure(live)

# Special case: metrics and api calls are sent every `api_call_interval` measures
if (
self._api_call_interval != -1
Expand Down
142 changes: 142 additions & 0 deletions codecarbon/viz/live.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CodeCarbon live</title>
<style>
:root { color-scheme: light dark; --fg: #1b1b1b; --bg: #fbfbfa; --muted: #6b6b6b; --line: #dcdcdc; --cpu: #3d7ea6; --gpu: #c05746; --ram: #6a9955; }
@media (prefers-color-scheme: dark) { :root { --fg: #ededed; --bg: #16181a; --muted: #9a9a9a; --line: #33373b; } }
body { margin: 0; padding: 1.5rem; background: var(--bg); color: var(--fg);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 1.2rem; }
h1 small { color: var(--muted); font-weight: 400; }
.cards { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem; }
.card { flex: 1 1 10rem; border: 1px solid var(--line); border-radius: 6px; padding: .8rem 1rem; }
.card .label { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; }
.card .value { font-size: 1.7rem; font-variant-numeric: tabular-nums; }
.card .unit { font-size: .9rem; color: var(--muted); }
svg { width: 100%; height: 220px; border: 1px solid var(--line); border-radius: 6px; }
.legend { display: flex; gap: 1rem; font-size: .85rem; color: var(--muted); margin: .5rem 0 1.5rem; }
.swatch { display: inline-block; width: .7rem; height: .7rem; border-radius: 2px; margin-right: .3rem; }
table { border-collapse: collapse; width: 100%; margin-bottom: 1.5rem; font-size: .9rem; }
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 500; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
#stale { color: #b45309; font-size: .85rem; }
</style>
</head>
<body>
<h1>CodeCarbon <small id="subtitle">waiting for the first measurement…</small></h1>

<div class="cards">
<div class="card"><div class="label">Power</div><div class="value"><span id="power">–</span> <span class="unit">W</span></div></div>
<div class="card"><div class="label">Emissions</div><div class="value"><span id="emissions">–</span> <span class="unit">gCO₂eq</span></div></div>
<div class="card"><div class="label">Energy</div><div class="value"><span id="energy">–</span> <span class="unit">kWh</span></div></div>
<div class="card"><div class="label">Elapsed</div><div class="value" id="duration">–</div></div>
</div>

<svg id="chart" viewBox="0 0 600 200" preserveAspectRatio="none"></svg>
<div class="legend">
<span><span class="swatch" style="background: var(--cpu)"></span>CPU</span>
<span><span class="swatch" style="background: var(--gpu)"></span>GPU</span>
<span><span class="swatch" style="background: var(--ram)"></span>RAM</span>
<span id="stale"></span>
</div>

<table id="components"><thead><tr><th>Component</th><th>Model</th><th class="num">Power (W)</th><th class="num">Load</th></tr></thead><tbody></tbody></table>
<table id="tasks" hidden><thead><tr><th>Task</th><th class="num">Energy (kWh)</th><th class="num">Emissions (g)</th><th class="num">Duration (s)</th></tr></thead><tbody></tbody></table>
<p id="meta" style="color: var(--muted); font-size: .85rem"></p>

<script>
"use strict";
var W = 600, H = 200;

function num(v, digits) {
return (typeof v === "number" && isFinite(v)) ? v.toFixed(digits) : "–";
}

function hms(seconds) {
var s = Math.max(0, Math.round(seconds || 0));
return [Math.floor(s / 3600), Math.floor(s / 60) % 60, s % 60]
.map(function (n) { return String(n).padStart(2, "0"); }).join(":");
}

function line(samples, key, max, color) {
// A polyline over the sample window; the y axis is shared by all three series.
var step = samples.length > 1 ? W / (samples.length - 1) : W;
var points = samples.map(function (s, i) {
var v = s[key] || 0;
return (i * step).toFixed(1) + "," + (H - (v / max) * (H - 10)).toFixed(1);
}).join(" ");
return '<polyline fill="none" stroke="' + color + '" stroke-width="2" points="' + points + '"/>';
}

function draw(samples) {
var chart = document.getElementById("chart");
if (!samples.length) { chart.innerHTML = ""; return; }
var max = 1;
samples.forEach(function (s) {
max = Math.max(max, s.cpu_power || 0, s.gpu_power || 0, s.ram_power || 0);
});
chart.innerHTML =
line(samples, "cpu_power", max, "var(--cpu)") +
line(samples, "gpu_power", max, "var(--gpu)") +
line(samples, "ram_power", max, "var(--ram)");
}

function rows(tbody, data) {
tbody.innerHTML = data.map(function (cells) {
return "<tr>" + cells.map(function (c, i) {
return "<td" + (i ? ' class="num"' : "") + ">" + c + "</td>";
}).join("") + "</tr>";
}).join("");
}

function render(payload) {
var samples = payload.samples || [];
var meta = payload.metadata || {};
var last = samples[samples.length - 1];
draw(samples);
if (!last) { return; }

var power = (last.cpu_power || 0) + (last.gpu_power || 0) + (last.ram_power || 0);
document.getElementById("power").textContent = num(power, 1);
document.getElementById("emissions").textContent = num(last.emissions_g, 3);
document.getElementById("energy").textContent = num(last.energy_consumed, 5);
document.getElementById("duration").textContent = hms(last.duration);
document.getElementById("subtitle").textContent =
(meta.project_name || "") + " · last sample " + last.timestamp;

rows(document.querySelector("#components tbody"), [
["CPU", meta.cpu_model || "–", num(last.cpu_power, 1), num(last.cpu_utilization_percent, 0) + " %"],
["GPU", meta.gpu_model || "not detected", num(last.gpu_power, 1), num(last.gpu_utilization_percent, 0) + " %"],
["RAM", num(meta.ram_total_size, 1) + " GB", num(last.ram_power, 1), num(last.ram_utilization_percent, 0) + " %"]
]);

var tasks = payload.tasks || [];
document.getElementById("tasks").hidden = tasks.length === 0;
rows(document.querySelector("#tasks tbody"), tasks.map(function (t) {
return [t.task_name, num(t.energy_consumed, 5), num(t.emissions * 1000, 3), num(t.duration, 0)];
}));

document.getElementById("meta").textContent =
[meta.country_name, meta.region, meta.tracking_mode && "mode: " + meta.tracking_mode,
meta.codecarbon_version && "codecarbon " + meta.codecarbon_version]
.filter(Boolean).join(" · ");
}

function poll() {
fetch("data").then(function (r) { return r.json(); }).then(function (payload) {
document.getElementById("stale").textContent = "";
render(payload);
}).catch(function () {
document.getElementById("stale").textContent = "disconnected";
});
}

poll();
setInterval(poll, 2000);
</script>
</body>
</html>
174 changes: 174 additions & 0 deletions codecarbon/viz/live.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""
Live local dashboard.

An output handler that keeps a bounded window of live measurements in memory and
serves them, with a single self-contained HTML page, over a stdlib HTTP server.
No dependency, no database, no network access: it is meant for watching a run on
the machine that is being measured.
"""

import dataclasses
import json
import threading
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib import resources
from typing import List

from codecarbon.external.logger import logger
from codecarbon.output_methods.base_output import BaseOutput
from codecarbon.output_methods.emissions_data import EmissionsData, TaskEmissionsData

# Fields kept for every sample. The rest of EmissionsData is either static
# (hardware, geography) or not useful on a live chart.
SAMPLE_FIELDS = (
"timestamp",
"duration",
"cpu_power",
"gpu_power",
"ram_power",
"energy_consumed",
"cpu_utilization_percent",
"gpu_utilization_percent",
"ram_utilization_percent",
)

METADATA_FIELDS = (
"project_name",
"experiment_id",
"run_id",
"cpu_count",
"cpu_model",
"gpu_count",
"gpu_model",
"ram_total_size",
"country_name",
"country_iso_code",
"region",
"os",
"python_version",
"codecarbon_version",
"tracking_mode",
)


def _page() -> bytes:
return resources.files("codecarbon.viz").joinpath("live.html").read_bytes()


class LiveDashboardOutput(BaseOutput):
"""
Serve a live view of the current run on http://<host>:<port>.

Usage::

tracker = EmissionsTracker(output_handlers=[LiveDashboardOutput()])

The handler keeps at most ``history`` samples in memory, so it is safe to
leave running for days. If the port is already taken the handler logs an
error and stays inert: a busy port must never take down a measurement run.
"""

def __init__(self, port: int = 8050, host: str = "127.0.0.1", history: int = 720):
self.port = port
self.host = host
self._history = deque(maxlen=history)
self._metadata = {}
self._tasks = []
self._lock = threading.Lock()
self._server = None
self._start_server()

def _start_server(self) -> None:
handler_self = self

class Handler(BaseHTTPRequestHandler):
def do_GET(self): # http.server API naming
if self.path.startswith("/data"):
self._respond(
200, "application/json", handler_self._snapshot().encode()
)
elif self.path.startswith("/health"):
self._respond(200, "application/json", b'{"status": "ok"}')
elif self.path == "/":
self._respond(200, "text/html; charset=utf-8", _page())
else:
self._respond(404, "text/plain", b"not found")

def _respond(self, status, content_type, body):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def log_message(self, *args):
"""Silence the default stderr access log."""

try:
self._server = ThreadingHTTPServer((self.host, self.port), Handler)
except OSError as e:
logger.error(
f"Live dashboard could not bind {self.host}:{self.port} ({e}). "
"Continuing without the live dashboard."
)
return

# The OS assigns the port when 0 was requested, so report the real one.
self.port = self._server.server_address[1]
if self.host not in ("127.0.0.1", "localhost", "::1"):
logger.warning(
f"Live dashboard is listening on {self.host}:{self.port} and is "
"not authenticated. Prefer 127.0.0.1 with SSH port forwarding."
)
threading.Thread(
target=self._server.serve_forever, daemon=True, name="codecarbon-live-ui"
).start()
logger.info(f"Live dashboard available on http://{self.host}:{self.port}")

@property
def is_serving(self) -> bool:
return self._server is not None

@property
def url(self) -> str:
return f"http://{self.host}:{self.port}"

def _snapshot(self) -> str:
with self._lock:
return json.dumps(
{
"samples": list(self._history),
"metadata": self._metadata,
"tasks": self._tasks,
}
)

def on_measure(self, total: EmissionsData):
"""
Record one sample. Called by the tracker after every measurement, with
the power fields holding the last measured power rather than the
average since ``start()``.

Defining this method is what opts the handler into the per-measurement
cadence; `live_out` and `out` are deliberately left as no-ops so the
chart has a single feed of comparable samples.
"""
values = total.values
sample = {k: values[k] for k in SAMPLE_FIELDS}
# Grams are what a human reads; kg is what the dataclass carries.
sample["emissions_g"] = total.emissions * 1000
with self._lock:
self._history.append(sample)
self._metadata = {k: values[k] for k in METADATA_FIELDS}

def task_out(self, data: List[TaskEmissionsData], experiment_name: str):
tasks = [dataclasses.asdict(task) for task in data]
with self._lock:
self._tasks = tasks

def exit(self):
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
Loading
Loading