diff --git a/src/digitaltwin/runtime.py b/src/digitaltwin/runtime.py
index 8183933..0cf6f46 100644
--- a/src/digitaltwin/runtime.py
+++ b/src/digitaltwin/runtime.py
@@ -428,6 +428,17 @@ def set_inference_task(self, task: Callable) -> None:
assert self.cmp_type in ["INVESTIGATOR"]
self._ant.inference_task = task
+ def record_output(self, name: str, dataurl: str) -> None:
+ """Publish the twin's latest visual output for the dashboard.
+
+ Any component may call this with a small `data:` URI (typically a
+ downscaled PNG it rendered). Only the most recent is kept and it
+ rides in every `twin_list` poll, so keep the payload small.
+ """
+
+ self._runtime.record_output(name, dataurl,
+ type(self._ant.component).__name__)
+
def get_inference_tasks(self) -> dict[int, Callable]:
"""Return a dictionary of the inference tasks keyed by investigator ID"""
@@ -725,6 +736,13 @@ def __init__(self, flow: WorkflowEngine, streamer: PubSubClient) -> None:
# uids above; a uid submitted with no component stays unattributed
self._task_comp: dict[str, str] = {}
+ # The twin's most recent visual output (e.g. a small rendered image
+ # a component produced), as a data-URI. Only the LATEST rides in
+ # `outputs()` -- one image per poll under the frame cap -- with a
+ # monotonic id the dashboard dedups on to build its own gallery.
+ self._output_latest: Optional[dict] = None
+ self._output_count: int = 0
+
hook_engine(flow)
# a stalled stream is a twin failure, not a log line
@@ -765,6 +783,37 @@ def task_components(self) -> dict[str, str]:
return dict(self._task_comp)
+ def record_output(self, name: str, dataurl: str,
+ component: Optional[str] = None) -> None:
+ """Record the twin's latest visual output (a small data-URI).
+
+ A component publishes a rendered result -- typically a downscaled
+ image it produced -- for the dashboard to show. Only the most
+ recent is kept: it carries a monotonic id so the consumer builds
+ its own history instead of the twin resending one each poll. Keep
+ the payload small (the whole `outputs()` rides in every poll, under
+ the frame cap).
+ """
+
+ self._output_count += 1
+ self._output_latest = {
+ "id": self._output_count,
+ "name": name,
+ "dataurl": dataurl,
+ "component": component,
+ }
+
+ def outputs(self) -> Optional[dict]:
+ """The twin's latest output plus a running count, or `None`.
+
+ `{"latest": {id, name, dataurl, component}, "count": n}` -- one
+ image, id-tagged for dedup; the dashboard accumulates the gallery.
+ """
+
+ if self._output_latest is None:
+ return None
+ return {"latest": self._output_latest, "count": self._output_count}
+
@property
def stream_config(self) -> PubSubConfig:
"""This twin's stream endpoint as plain data (see `PubSubConfig`).
diff --git a/src/digitaltwin/service/session.py b/src/digitaltwin/service/session.py
index 398232d..7ef8a62 100644
--- a/src/digitaltwin/service/session.py
+++ b/src/digitaltwin/service/session.py
@@ -190,6 +190,10 @@ def summary(self) -> dict:
# only -- never a model, so the entry stays poll-sized.
"components": ([] if self.runtime is None
else self.runtime.describe()["components"]),
+ # The twin's latest visual output (a small data-URI image a
+ # component rendered), id-tagged so the dashboard dedups and
+ # builds its own gallery. `None` until something is recorded.
+ "outputs": None if self.runtime is None else self.runtime.outputs(),
}
def ready(self, runtime: DTRuntime, stream: PubSubClient) -> None:
diff --git a/src/digitaltwin/service/ui/dt_dash.js b/src/digitaltwin/service/ui/dt_dash.js
index 9130d43..31792e9 100644
--- a/src/digitaltwin/service/ui/dt_dash.js
+++ b/src/digitaltwin/service/ui/dt_dash.js
@@ -77,7 +77,7 @@
(() => {
- const VERSION = '0.10.2';
+ const VERSION = '0.11.0';
const SCHEMA = 'dt-dash-recording/1';
// -------------------------------------------------------------------------
@@ -324,6 +324,21 @@
if (Array.isArray(t.components)) tw.components = t.components;
applyCalls(w, tw, t.calls);
applyTasks(w, id, t.tasks, t.task_components);
+ applyOutputs(tw, t.outputs);
+ }
+
+ // The twin resends only its LATEST output each poll, id-tagged; keep a
+ // bounded gallery here, appending when a new id arrives.
+ const OUTPUT_GALLERY_MAX = 8;
+ function applyOutputs(tw, outputs) {
+ if (!outputs || !outputs.latest || !outputs.latest.dataurl) return;
+ if (!tw.gallery) tw.gallery = [];
+ const latest = outputs.latest;
+ const last = tw.gallery[tw.gallery.length - 1];
+ if (last && last.id === latest.id) return; // already have it
+ tw.gallery.push(latest);
+ if (tw.gallery.length > OUTPUT_GALLERY_MAX) tw.gallery.shift();
+ tw.outputCount = outputs.count;
}
// The twin's own record of what it submitted (`TASK_UID_RING` in the
@@ -1025,6 +1040,41 @@
return card;
}
+ // The twin's rendered outputs (e.g. heatmaps) as they arrive: the
+ // newest large, a strip of recent thumbnails behind it. Fed from
+ // `tw.gallery` (see applyOutputs).
+ function outputsBlock(tw) {
+ const block = el('div', 'dtd-agent');
+ const head = el('div', 'dtd-agent-head');
+ const latest = tw.gallery[tw.gallery.length - 1];
+ head.appendChild(el('span', 'dtd-agent-name', '▾ Outputs'));
+ head.appendChild(el('span', 'dtd-agent-sel',
+ `${tw.outputCount || tw.gallery.length} total · ${latest.name}`));
+ block.appendChild(head);
+
+ const big = document.createElement('img');
+ big.className = 'dtd-output-latest';
+ big.src = latest.dataurl;
+ big.alt = latest.name;
+ big.title = `${latest.name} (${latest.component || ''})`;
+ block.appendChild(big);
+
+ if (tw.gallery.length > 1) {
+ const strip = el('div', 'dtd-output-strip');
+ // newest first, skip the one already shown big
+ for (let i = tw.gallery.length - 2; i >= 0; i--) {
+ const g = tw.gallery[i];
+ const t = document.createElement('img');
+ t.className = 'dtd-output-thumb';
+ t.src = g.dataurl;
+ t.title = `${g.name} (${g.component || ''})`;
+ strip.appendChild(t);
+ }
+ block.appendChild(strip);
+ }
+ return block;
+ }
+
function agentBlock(tw, comp, index) {
const key = `agent:${tw.id}|${comp.component}`;
const closed = collapsed.has(key);
@@ -1109,6 +1159,9 @@
card.appendChild(el('div', 'dtd-card-error', tw.last_error));
}
blocks.forEach((b, i) => card.appendChild(agentBlock(tw, b, i)));
+ if (tw.gallery && tw.gallery.length) {
+ card.appendChild(outputsBlock(tw));
+ }
}
if (gone) {
@@ -1136,8 +1189,10 @@
.join('.');
const mvals = Object.entries(tw.metrics || {})
.map(([k, m]) => `${k}:${m && m.value}`).join(',');
+ const gal = tw.gallery && tw.gallery.length
+ ? tw.gallery[tw.gallery.length - 1].id : '';
return `${tw.id}|${tw.state}|${tw.gone !== null}|${tw.last_error || ''}`
- + `|${mark ? mark.label : ''}|${comps}|${mvals}`;
+ + `|${mark ? mark.label : ''}|${comps}|${mvals}|${gal}`;
}).join(';');
return `${tick}#${keys}#${twins}`;
}
@@ -2725,6 +2780,11 @@
white-space: nowrap; max-width: 60%; }
.dtd-agent-io { font: 400 9px ${FONT_MONO}; color: ${C.text_dim};
margin: 2px 0 4px; }
+.dtd-output-latest { display: block; max-width: 100%; border-radius: 3px;
+ margin: 4px 0 3px; background: ${C.panel_deep}; }
+.dtd-output-strip { display: flex; gap: 3px; flex-wrap: wrap; }
+.dtd-output-thumb { width: 40px; height: 40px; object-fit: cover;
+ border-radius: 2px; opacity: 0.8; background: ${C.panel_deep}; }
.dtd-inv { border: 1px solid; border-radius: 3px; margin: 4px 0 0 14px;
padding: 4px 7px 5px; opacity: 0.92; }
.dtd-inv-head { display: flex; align-items: baseline; gap: 8px; }
diff --git a/src/digitaltwin/service/ui/index.html b/src/digitaltwin/service/ui/index.html
index 9631089..13507dd 100644
--- a/src/digitaltwin/service/ui/index.html
+++ b/src/digitaltwin/service/ui/index.html
@@ -70,8 +70,8 @@
header draws -- if the two disagree, the page is stale. The plugin
serves these by path and ignores the query, so the live host is
unaffected. -->
-
-
+
+