Die gemeinsame Plattform ist jetzt softwareseitig deutlich vollständiger. Der Host-Core hat mit [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) eine echte Runtime-Bibliothek und Persistenz für aktive Szene, Runtime-Presets, Runtime-Gruppen und kreative Varianten bekommen; die Simulation in [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>) liefert jetzt typisierte Command-Ergebnisse, saubere Fehlercodes und persistiert nach data/runtime_state.json. Dazu kommt das generische External-Show-Control-Interface in [external_control.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/external_control.rs>), damit spätere Adapter nur auf definierte Commands und Snapshot-/Preset-/Parameter-Flächen zugreifen.

Die API v1 ist als Produktgrenze geschärft in [dto.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/dto.rs>) und [server.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/src/server.rs>): getrennte Modelle für `state`, `preview`, `snapshot`, `command response`, `event stream` und stabile Fehlerobjekte mit echten Codes statt generischem Fallback. Dazu kamen `GET /api/v1/state` und `GET /api/v1/preview`, neue persistenzbezogene Commands wie `save_preset`, `save_creative_snapshot`, `recall_creative_snapshot`, `set_transition_style` und `upsert_group`, plus serverseitige Durchreichung der echten Fehlercodes. Die kreative Web-UI in [index.html](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/index.html>), [app.js](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/app.js>) und [styles.css](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/web/v1/styles.css>) nutzt jetzt genau diese API für Preset-Speichern/Überschreiben, Varianten, Transition-Style, filterbaren Eventfeed und klarere Preview-Darstellung, ohne Parallelarchitektur.

Die Doku ist auf den neuen Stand gezogen in [docs/host_api.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/host_api.md>), [README.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/README.md>), [docs/build_and_deploy.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/build_and_deploy.md>) und [docs/architecture.md](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/docs/architecture.md>). Verifiziert habe ich `cargo check -q` und `cargo test -q`; dabei laufen die erweiterten Contract- und Persistenztests in [contract.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host_api/tests/contract.rs>) sowie neue Core-Tests in [show_store.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/show_store.rs>) und [simulation.rs](</C:/Users/janni/Documents/RFP/Infinity_Vis _Rust/crates/infinity_host/src/simulation.rs>). Nicht separat verifiziert habe ich einen echten Browserlauf der Web-UI; die JS-Datei wurde hier nicht mit `node` geprüft, weil `node` in dieser Umgebung nicht installiert ist.
This commit is contained in:
2026-04-17 12:34:03 +02:00
parent a37a3c5cbe
commit 8e19f535ae
20 changed files with 2399 additions and 459 deletions

View File

@@ -1,9 +1,8 @@
(function () {
const apiState = {
snapshot: null,
stateResponse: null,
previewResponse: null,
catalog: null,
presets: [],
groups: [],
events: [],
ws: null,
commandTimers: new Map(),
@@ -18,15 +17,27 @@
patternSelect: document.getElementById("pattern-select"),
transitionSlider: document.getElementById("transition-slider"),
transitionValue: document.getElementById("transition-value"),
transitionStyleSelect: document.getElementById("transition-style-select"),
brightnessSlider: document.getElementById("brightness-slider"),
brightnessValue: document.getElementById("brightness-value"),
blackoutButton: document.getElementById("blackout-button"),
presetList: document.getElementById("preset-list"),
presetIdInput: document.getElementById("preset-id-input"),
presetOverwriteInput: document.getElementById("preset-overwrite-input"),
savePresetButton: document.getElementById("save-preset-button"),
groupFilterInput: document.getElementById("group-filter-input"),
groupList: document.getElementById("group-list"),
snapshotIdInput: document.getElementById("snapshot-id-input"),
snapshotLabelInput: document.getElementById("snapshot-label-input"),
snapshotOverwriteInput: document.getElementById("snapshot-overwrite-input"),
saveSnapshotButton: document.getElementById("save-snapshot-button"),
snapshotList: document.getElementById("snapshot-list"),
sceneParams: document.getElementById("scene-params"),
previewGrid: document.getElementById("preview-grid"),
summaryCards: document.getElementById("summary-cards"),
snapshotJson: document.getElementById("snapshot-json"),
eventKindFilter: document.getElementById("event-kind-filter"),
eventSearchFilter: document.getElementById("event-search-filter"),
eventList: document.getElementById("event-list"),
};
@@ -49,12 +60,19 @@
dom.transitionSlider.addEventListener("input", (event) => {
const value = Number(event.target.value);
dom.transitionValue.textContent = `${value} ms`;
debounceCommand("transition", {
debounceCommand("transition-duration", {
type: "set_transition_duration_ms",
payload: { duration_ms: value },
});
});
dom.transitionStyleSelect.addEventListener("change", (event) => {
sendCommand({
type: "set_transition_style",
payload: { style: event.target.value },
});
});
dom.brightnessSlider.addEventListener("input", (event) => {
const value = Number(event.target.value);
dom.brightnessValue.textContent = `${Math.round(value * 100)}%`;
@@ -65,36 +83,83 @@
});
dom.blackoutButton.addEventListener("click", () => {
const enabled = !(apiState.snapshot?.state?.global?.blackout ?? false);
const enabled = !(apiState.stateResponse?.state?.global?.blackout ?? false);
sendCommand({
type: "set_blackout",
payload: { enabled },
});
});
dom.savePresetButton.addEventListener("click", async () => {
const presetId = dom.presetIdInput.value.trim();
if (!presetId) {
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "warning",
code: "preset_id_required",
message: "Preset ID is required before saving.",
});
return;
}
await sendCommand({
type: "save_preset",
payload: {
preset_id: presetId,
overwrite: dom.presetOverwriteInput.checked,
},
});
});
dom.saveSnapshotButton.addEventListener("click", async () => {
const snapshotId = dom.snapshotIdInput.value.trim();
if (!snapshotId) {
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "warning",
code: "snapshot_id_required",
message: "Snapshot ID is required before saving a creative variant.",
});
return;
}
await sendCommand({
type: "save_creative_snapshot",
payload: {
snapshot_id: snapshotId,
label: dom.snapshotLabelInput.value.trim() || null,
overwrite: dom.snapshotOverwriteInput.checked,
},
});
});
dom.groupFilterInput.addEventListener("input", () => renderGroups());
dom.eventKindFilter.addEventListener("change", () => renderEvents());
dom.eventSearchFilter.addEventListener("input", () => renderEvents());
}
async function refreshAll() {
setConnectionState("connecting", "loading");
try {
const [snapshot, catalog, presets, groups] = await Promise.all([
fetchJson("/api/v1/snapshot"),
const [stateResponse, previewResponse, catalog] = await Promise.all([
fetchJson("/api/v1/state"),
fetchJson("/api/v1/preview"),
fetchJson("/api/v1/catalog"),
fetchJson("/api/v1/presets"),
fetchJson("/api/v1/groups"),
]);
apiState.snapshot = snapshot;
apiState.stateResponse = stateResponse;
apiState.previewResponse = previewResponse;
apiState.catalog = catalog;
apiState.presets = presets.presets || catalog.presets || [];
apiState.groups = groups.groups || catalog.groups || [];
renderAll();
setConnectionState("online", "HTTP snapshot synced");
setConnectionState("online", "HTTP sync");
} catch (error) {
console.error(error);
setConnectionState("offline", "snapshot fetch failed");
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "error",
code: "http_refresh_failed",
message: `HTTP refresh failed: ${error.message}`,
});
}
@@ -110,6 +175,8 @@
setConnectionState("online", "stream connected");
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "info",
code: "stream_connected",
message: "WebSocket stream connected",
});
});
@@ -123,6 +190,8 @@
setConnectionState("offline", "stream disconnected");
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "warning",
code: "stream_reconnect",
message: "WebSocket stream closed, retrying",
});
window.setTimeout(connectStream, 1500);
@@ -139,26 +208,22 @@
return;
}
if (!apiState.snapshot) {
apiState.snapshot = {
if (message.type === "snapshot") {
apiState.stateResponse = {
api_version: envelope.api_version,
generated_at_millis: envelope.generated_at_millis,
state: null,
preview: null,
state: message.payload,
};
}
if (message.type === "snapshot") {
apiState.snapshot.api_version = envelope.api_version;
apiState.snapshot.generated_at_millis = envelope.generated_at_millis;
apiState.snapshot.state = message.payload;
renderState();
return;
}
if (message.type === "preview") {
apiState.snapshot.preview = message.payload;
apiState.snapshot.generated_at_millis = envelope.generated_at_millis;
apiState.previewResponse = {
api_version: envelope.api_version,
generated_at_millis: envelope.generated_at_millis,
preview: message.payload,
};
renderPreview();
renderSnapshotJson();
return;
@@ -167,6 +232,8 @@
if (message.type === "event") {
pushEvent({
at: `${envelope.generated_at_millis} ms`,
kind: message.payload.kind || "info",
code: message.payload.code || null,
message: message.payload.message,
});
}
@@ -184,15 +251,21 @@
});
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "info",
code: response.command_type,
message: response.summary,
});
await refreshAll();
return response;
} catch (error) {
console.error(error);
pushEvent({
at: new Date().toLocaleTimeString(),
kind: "error",
code: "command_failed",
message: `Command failed: ${error.message}`,
});
return null;
}
}
@@ -232,17 +305,16 @@
}
function renderState() {
if (!apiState.snapshot?.state) {
const state = apiState.stateResponse?.state;
if (!state) {
return;
}
const snapshot = apiState.snapshot;
const state = snapshot.state;
const global = state.global;
const scene = state.active_scene;
dom.projectName.textContent = state.system.project_name;
dom.topologyLabel.textContent = `${state.system.topology_label} / API ${snapshot.api_version}`;
dom.topologyLabel.textContent = `${state.system.topology_label} / API ${apiState.stateResponse.api_version}`;
dom.patternSelect.innerHTML = "";
(apiState.catalog?.patterns || []).forEach((pattern) => {
@@ -255,22 +327,23 @@
dom.transitionSlider.value = String(global.transition_duration_ms);
dom.transitionValue.textContent = `${global.transition_duration_ms} ms`;
dom.transitionStyleSelect.value = global.transition_style;
dom.brightnessSlider.value = String(global.master_brightness);
dom.brightnessValue.textContent = `${Math.round(global.master_brightness * 100)}%`;
dom.blackoutButton.textContent = global.blackout ? "Release blackout" : "Enable blackout";
dom.blackoutButton.classList.toggle("is-active", global.blackout);
renderPresetButtons(scene, global);
renderGroupButtons(global);
renderPresets(scene);
renderGroups(global);
renderCreativeSnapshots();
renderSceneParameters(scene);
renderSummaryCards(state, snapshot.generated_at_millis);
renderSummaryCards(state);
renderSnapshotJson();
dom.previewUpdated.textContent = `${snapshot.generated_at_millis} ms`;
}
function renderPresetButtons(scene) {
function renderPresets(scene) {
dom.presetList.innerHTML = "";
const presets = apiState.presets || [];
const presets = apiState.catalog?.presets || [];
if (!presets.length) {
dom.presetList.innerHTML = '<div class="empty-state">No presets available.</div>';
return;
@@ -283,7 +356,7 @@
button.classList.toggle("active", scene.preset_id === preset.preset_id);
button.innerHTML = `
<strong>${preset.preset_id}</strong>
<div class="pill-subtext">${preset.pattern_id} / ${preset.transition_duration_ms} ms</div>
<div class="pill-subtext">${preset.pattern_id} / ${preset.transition_style} / ${preset.source}</div>
`;
button.addEventListener("click", () =>
sendCommand({
@@ -295,8 +368,18 @@
});
}
function renderGroupButtons(global) {
function renderGroups(global) {
dom.groupList.innerHTML = "";
const filterValue = dom.groupFilterInput.value.trim().toLowerCase();
const groups = (apiState.catalog?.groups || []).filter((group) => {
if (!filterValue) {
return true;
}
return (
group.group_id.toLowerCase().includes(filterValue) ||
(group.tags || []).some((tag) => tag.toLowerCase().includes(filterValue))
);
});
const allButton = document.createElement("button");
allButton.type = "button";
@@ -311,14 +394,22 @@
);
dom.groupList.appendChild(allButton);
(apiState.groups || []).forEach((group) => {
if (!groups.length) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = "No groups match the current filter.";
dom.groupList.appendChild(empty);
return;
}
groups.forEach((group) => {
const button = document.createElement("button");
button.type = "button";
button.className = "group-button";
button.classList.toggle("active", group.group_id === global.selected_group);
button.innerHTML = `
<strong>${group.group_id}</strong>
<div class="pill-subtext">${group.member_count} members</div>
<div class="pill-subtext">${group.member_count} members / ${group.source}</div>
`;
button.addEventListener("click", () =>
sendCommand({
@@ -330,6 +421,43 @@
});
}
function renderCreativeSnapshots() {
dom.snapshotList.innerHTML = "";
const snapshots = apiState.catalog?.creative_snapshots || [];
if (!snapshots.length) {
dom.snapshotList.innerHTML =
'<div class="empty-state">No creative snapshots saved yet.</div>';
return;
}
snapshots.forEach((snapshot) => {
const card = document.createElement("article");
card.className = "snapshot-card";
card.innerHTML = `
<div class="snapshot-card-header">
<div>
<strong>${snapshot.label || snapshot.snapshot_id}</strong>
<div class="preview-meta">${snapshot.snapshot_id}</div>
</div>
<button type="button" class="ghost-button">Recall</button>
</div>
<div class="snapshot-meta-row">
<span class="meta-chip">${snapshot.pattern_id}</span>
<span class="meta-chip">${snapshot.transition_style}</span>
<span class="meta-chip">${snapshot.transition_duration_ms} ms</span>
<span class="meta-chip">${snapshot.target_group || "all_panels"}</span>
</div>
`;
card.querySelector("button").addEventListener("click", () =>
sendCommand({
type: "recall_creative_snapshot",
payload: { snapshot_id: snapshot.snapshot_id },
})
);
dom.snapshotList.appendChild(card);
});
}
function renderSceneParameters(scene) {
dom.sceneParams.innerHTML = "";
const parameters = scene.parameters || [];
@@ -417,7 +545,7 @@
}
function renderPreview() {
const preview = apiState.snapshot?.preview;
const preview = apiState.previewResponse?.preview;
dom.previewGrid.innerHTML = "";
if (!preview?.panels?.length) {
dom.previewGrid.innerHTML =
@@ -425,6 +553,7 @@
return;
}
dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`;
const panels = [...preview.panels].sort(comparePreviewPanels);
panels.forEach((panel) => {
const card = document.createElement("article");
@@ -439,6 +568,7 @@
<strong>${panel.energy_percent}%</strong>
</div>
<div class="preview-swatch"></div>
<div class="energy-bar"><span style="--energy-width: ${panel.energy_percent}%"></span></div>
<div class="sample-row">
${panel.sample_led_hex
.map(
@@ -452,11 +582,12 @@
});
}
function renderSummaryCards(state, generatedAtMillis) {
function renderSummaryCards(state) {
const scene = state.active_scene;
const global = state.global;
const engine = state.engine;
const nodeStats = summarizeNodes(state.nodes || []);
const creativeSnapshotCount = (apiState.catalog?.creative_snapshots || []).length;
const cards = [
{
@@ -467,11 +598,11 @@
{
label: "Group Target",
value: scene.target_group || "all_panels",
detail: `${(apiState.groups || []).length} groups available`,
detail: `${(apiState.catalog?.groups || []).length} groups available`,
},
{
label: "Transition",
value: `${global.transition_duration_ms} ms`,
value: `${global.transition_style} / ${global.transition_duration_ms} ms`,
detail: engine.active_transition
? `${engine.active_transition.style} ${Math.round(engine.active_transition.progress * 100)}%`
: "idle",
@@ -492,9 +623,9 @@
detail: `${nodeStats.degraded} degraded / ${nodeStats.offline} offline`,
},
{
label: "Preview Timestamp",
value: `${generatedAtMillis} ms`,
detail: `${state.system.schema_version} schema`,
label: "Creative Snapshots",
value: `${creativeSnapshotCount}`,
detail: `${(apiState.catalog?.presets || []).length} presets in library`,
},
];
@@ -512,28 +643,55 @@
}
function renderSnapshotJson() {
dom.snapshotJson.textContent = apiState.snapshot
? JSON.stringify(apiState.snapshot, null, 2)
: "No snapshot loaded.";
dom.snapshotJson.textContent = JSON.stringify(buildComposedSnapshot(), null, 2);
}
function buildComposedSnapshot() {
return {
api_version: apiState.stateResponse?.api_version || apiState.previewResponse?.api_version || "v1",
generated_at_millis:
apiState.previewResponse?.generated_at_millis ||
apiState.stateResponse?.generated_at_millis ||
0,
state: apiState.stateResponse?.state || null,
preview: apiState.previewResponse?.preview || null,
catalog: apiState.catalog || null,
};
}
function pushEvent(entry) {
apiState.events.unshift(entry);
apiState.events = apiState.events.slice(0, 12);
apiState.events.unshift({
kind: entry.kind || "info",
code: entry.code || null,
...entry,
});
apiState.events = apiState.events.slice(0, 50);
renderEvents();
}
function renderEvents() {
if (!apiState.events.length) {
dom.eventList.innerHTML = '<div class="empty-state">No websocket notices yet.</div>';
const kindFilter = dom.eventKindFilter.value;
const searchFilter = dom.eventSearchFilter.value.trim().toLowerCase();
const filtered = apiState.events.filter((entry) => {
const kindMatches = kindFilter === "all" || entry.kind === kindFilter;
const searchMatches =
!searchFilter ||
(entry.message || "").toLowerCase().includes(searchFilter) ||
(entry.code || "").toLowerCase().includes(searchFilter);
return kindMatches && searchMatches;
});
if (!filtered.length) {
dom.eventList.innerHTML = '<div class="empty-state">No events match the current filter.</div>';
return;
}
dom.eventList.innerHTML = apiState.events
dom.eventList.innerHTML = filtered
.map(
(entry) => `
<article class="event-item">
<article class="event-item event-${entry.kind}">
<div class="event-meta">${entry.at}</div>
${entry.code ? `<span class="event-code">${entry.code}</span>` : ""}
<strong>${entry.message}</strong>
</article>
`