Skip to content
Merged
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
49 changes: 49 additions & 0 deletions src/digitaltwin/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`).
Expand Down
4 changes: 4 additions & 0 deletions src/digitaltwin/service/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
64 changes: 62 additions & 2 deletions src/digitaltwin/service/ui/dt_dash.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@

(() => {

const VERSION = '0.10.2';
const VERSION = '0.11.0';
const SCHEMA = 'dt-dash-recording/1';

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -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; }
Expand Down
4 changes: 2 additions & 2 deletions src/digitaltwin/service/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<script src="dt_dash.js?v=0.10.2"></script>
<script src="dt_sample.js?v=0.10.2"></script>
<script src="dt_dash.js?v=0.11.0"></script>
<script src="dt_sample.js?v=0.11.0"></script>
<script>
(() => {
const host = document.getElementById('dash');
Expand Down
Loading