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. --> - - + +