feat: add diagnostics page and functionality, enhance transport settings in index.html

- Introduced a new diagnostics page (diagnostics.html) with real-time monitoring for Art-Net, sACN, MQTT, and KNX.
- Added JavaScript functionality (diagnostics.js) to handle diagnostics data and render updates.
- Enhanced index.html with new transport settings including timeline length, beat snap options, and diagnostics button.
- Updated styles.css to accommodate new diagnostics layout and transport settings.
- Added splash screen (splash.html) for improved user experience during startup.
- Included new assets: Time4Midi.psd and updated executable files for version 0.3.6.
This commit is contained in:
jhartworks
2026-06-17 14:57:38 +02:00
parent b65b0461bc
commit b5c6d33c2d
14 changed files with 2315 additions and 100 deletions
+269 -27
View File
@@ -1,42 +1,284 @@
<<<<<<< HEAD
# Time4Midi # Time4Midi
Desktop-Prototyp fuer einen plattformuebergreifenden Timeline-Editor fuer: <p align="center">
<img src="./time4midi.png" alt="Time4Midi Logo" width="220">
</p>
- MIDI Notes <p align="center">
- Art-Net DMX Values Desktop-Timeline fuer Show-Control, MIDI, DMX, Audio und technische Automationen.
- externe Synchronisation via MIDI Timecode (MTC) </p>
- externe Synchronisation via Art-Net Timecode
## Aktueller Stand <p align="center">
<img src="./JHArtworks2023_wh.png" alt="J.H. Artworks" width="180">
</p>
Die erste Version enthaelt bereits: ## Was ist Time4Midi?
- mehrere Timelines pro Projekt Time4Midi ist eine Electron-Desktop-App fuer zeitbasierte Show-Automation. Du legst Punkte auf einer Timeline an und steuerst damit je nach Track-Typ zum Beispiel:
- Speichern und Laden als JSON
- Tracks fuer `MIDI Note` und `Art-Net Channel`
- Punkt-/Keyframe-Editing direkt auf dem Zeitstrahl
- `Hold` und `Linear` pro Segment
- konfigurierbare MIDI In/Out Ports
- konfigurierbares Art-Net Ziel inklusive Broadcast/Unicast
- Live-Playhead fuer `Internal`, `MTC` und `Art-Net Timecode`
## Starten - MIDI Notes und MIDI CC
- Art-Net und sACN / E1.31
- OSC
- MQTT
- Serial
- Modbus TCP / RTU
- KNX
- Audio-Tracks mit Waveform, Beat-Raster und Fade-Automation
Die App ist darauf ausgelegt, waehrend einer Show schnell lesbar zu bleiben: links Projekt und Tracks, in der Mitte die Timeline, rechts der Inspector fuer den aktuell gewaehlten Track oder Punkt.
## Auf einen Blick
- Mehrere Timelines pro Projekt
- Freie Timeline-Laenge bis 24 Stunden
- Zoomen mit `Strg + Mausrad`
- Horizontal scrollen mit dem Mausrad
- Auto-Follow des Timecursors beim Abspielen
- Audio-Waveform mit Beat-, Takt- und Drop-Markern
- Beat-Snap fuer Punkte auf anderen Tracks
- gelbe Snap-Linien ueber alle Tracks mit einstellbarer Staerke
- Diagnosefenster fuer Art-Net, sACN, MQTT und KNX
- Mapping-Bibliotheken fuer wiederverwendbare Zieldefinitionen
- Splashscreen beim Start mit Logo und Ladebalken
- Speichern und Laden als JSON-Projektdatei
## Bedienkonzept
```mermaid
flowchart LR
A["Projekt anlegen"] --> B["Timeline waehlen"]
B --> C["Tracks hinzufuegen"]
C --> D["Punkte setzen oder Audio laden"]
D --> E["Ausgaenge konfigurieren"]
E --> F["Play / Timecode / Diagnose"]
```
## Aufbau der Oberflaeche
```mermaid
flowchart TB
A["Sidebar<br/>Projekt, Timelines, Tracks"] --> B["Editor<br/>Timeline, Zoom, Scroll, Playhead"]
B --> C["Inspector<br/>Track- und Punktparameter"]
B --> D["Transportleiste<br/>Play, Sync, Timeline-Laenge, Beat-Snap"]
D --> E["Diagnosefenster<br/>Live-Werte fuer Netzwerk-Outputs"]
```
## Wichtige Features im Detail
### 1. Timeline und Keyframes
- Punkte lassen sich per Doppelklick setzen.
- Punkte koennen mit der Maus verschoben werden.
- Je nach Track-Typ stehen `Hold` oder `Linear` als Interpolation zur Verfuegung.
- Die Timeline-Laenge kann direkt oben in der Transportleiste eingestellt werden.
### 2. Audio-Analyse
Audio-Tracks koennen mehr als nur abspielen:
- Waveform wird direkt in der Timeline angezeigt
- erkannte Beats werden als feine Marker eingezeichnet
- erkannte Taktanfange werden hervorgehoben
- erkannte Drops werden markiert
- Audio-Clips koennen auf der Timeline nach links und rechts verschoben werden
- Fade-Punkte im Audiotrack steuern den Gain ueber die Zeit
- das aktuell gewaehlte Snap-Muster wird als gelbes Raster sichtbar
Hinweis:
Wenn ein geladenes Audio laenger ist als die aktuelle Timeline, wird die Timeline automatisch verlaengert. Ist die Timeline schon laenger als das Audio, bleibt sie unveraendert.
### 3. Beat-Snap
Wenn ein Audiotrack analysiert wurde, kann dessen Beat-Raster als Fangnetz fuer andere Tracks dienen.
- `Beat Snap` aktivieren
- `Snap Raster` waehlen
- Punkte auf MIDI-, DMX-, OSC-, MQTT- oder anderen Tracks setzen oder ziehen
- Punkte rasten auf das Audio-Raster ein
Verfuegbare Raster:
- `1/4 Beat`
- `1/2 Beat`
- `1 Beat`
- `2 Beats`
- `1 Takt`
- `2 Takte`
Damit kannst du z. B. in einem 4/4-Track nur jeden zweiten Takt oder eben halbe Beats als Raster verwenden.
Zusaetzlich:
- das aktive Snap-Muster wird als gelbe Linien in der Timeline gespiegelt
- die Linienstaerke kann direkt oben in der Transportleiste eingestellt werden
- Punkte lassen sich trotz Snap weiter sauber ziehen und springen stabil Raster fuer Raster
### 4. Diagnosefenster
Das Diagnosefenster ist gedacht, damit du waehrend einer laufenden Show sehen kannst, was wirklich rausgeht.
Aktuell enthalten:
- Art-Net Universe + aktive Kanaele
- sACN Universe + aktive Kanaele
- MQTT Topics + letzter gesendeter Wert
- KNX Gruppenadresse + letzter gesendeter Wert
- Verlauf der letzten Events
### 5. Mapping-Bibliotheken
Mappings koennen projektunabhaengig gespeichert werden. Damit kannst du technische Ziele mit sprechenden Namen versehen, zum Beispiel:
- `MIDI Note 1 = Strobe`
- `Art-Net Universe 4 / Channel 7 = Shutter`
- `KNX 1/2/20 = Preset Licht`
Diese Bibliotheken lassen sich in den Einstellungen pro Treiber pflegen, speichern und laden.
## Unterstuetzte Sync-Quellen
- Internal
- MIDI Timecode `MTC`
- Art-Net Timecode
## Unterstuetzte Track-Typen
| Bereich | Track-Typen |
| --- | --- |
| MIDI | Note, CC |
| DMX | Art-Net Channel, sACN Channel |
| Netzwerk | OSC, MQTT |
| Feldbus / Technik | KNX, Modbus TCP, Modbus RTU |
| Serielle Ausgaben | Serial |
| Media | Audio File |
## Quick Start
### Voraussetzungen
- Node.js
- npm
- Windows fuer den Portable-Build
### Entwicklung starten
```bash ```bash
npm install npm install
npm start npm start
``` ```
Unter Windows kannst du alternativ einfach die Datei `start-time4midi.cmd` im Projektordner doppelklicken. Unter Windows kannst du alternativ auch `start-time4midi.cmd` benutzen.
## Naechste sinnvolle Ausbaustufen ### Windows Portable Build
1. Mehr Track-Typen: MIDI CC, Program Change, komplette DMX-Ranges ```bash
2. Copy/Paste, Zoom, Snapping, Loop, Undo/Redo npm run dist:win
3. bessere MTC-Unterstuetzung inkl. Full-Frame und Locate ```
4. Projektbrowser, mehrere Cue-Listen, Szenen und Trigger
5. Packaging fuer Windows, macOS und Linux ## Schritt-fuer-Schritt Anleitung
=======
# Time4Midi ### Ein erstes Projekt anlegen
>>>>>>> 15ae412fbc06f021a6c052af2af6ea2f12d870e6
1. App starten.
2. Ein neues Projekt anlegen.
3. Eine Timeline waehlen oder eine neue erzeugen.
4. Oben die gewuenschte Timeline-Laenge eintragen.
5. Die benoetigten Tracks links hinzufuegen.
### MIDI oder DMX programmieren
1. Einen passenden Track hinzufuegen.
2. Rechts im Inspector Ziel, Kanal, Universe oder Adresse setzen.
3. Punkte per Doppelklick in die Timeline setzen.
4. Werte und Interpolation im Inspector feinjustieren.
### Audio laden und als Raster nutzen
1. Einen `Audio`-Track anlegen.
2. Eine Datei waehlen.
3. Warten, bis Waveform und Analyse erscheinen.
4. Falls gewuenscht `Beat Snap` aktivieren.
5. Das `Snap Raster` waehlen.
6. Optional die Staerke der gelben Snap-Linien einstellen.
7. Punkte auf anderen Tracks setzen oder verschieben.
### Audio-Fades zeichnen
1. Den Audio-Track auswaehlen.
2. Per Doppelklick direkt im Audiotrack Fade-Punkte setzen.
3. Punkte ziehen, um Zeit und Gain zu aendern.
4. Im Inspector `Zeit im Clip`, `Gain` und Interpolation anpassen.
### Tastatursteuerung fuer Snap
Wenn ein Punkt markiert ist:
1. `Pfeil links` springt zum vorherigen Beat bzw. Takt des aktiven Snap-Rasters.
2. `Pfeil rechts` springt zum naechsten Beat bzw. Takt.
3. `Strg + Pfeil links/rechts` springt um 4 Takte zurueck oder vor.
4. `Bild auf / Bild ab` scrollt horizontal durch die Timeline.
### Netzwerk-Ausgaenge pruefen
1. Transport starten.
2. `Diagnose` oeffnen.
3. Live beobachten, welche Werte auf welchen Universes, Kanaelen oder Topics ankommen.
## Projektdateien
Time4Midi speichert Projekte als JSON. Darin enthalten sind unter anderem:
- Projektname
- Timelines
- Tracks
- Punkte / Automation
- Routing
- ausgewaehlte Sync-Quelle
Mapping-Bibliotheken werden getrennt vom Projekt gespeichert und koennen in mehreren Projekten wiederverwendet werden.
## Dateien im Projekt
| Datei / Ordner | Bedeutung |
| --- | --- |
| `main.js` | Electron Main Process |
| `preload.js` | Bridge zwischen Renderer und Node / Treibern |
| `src/app.js` | Hauptlogik der App |
| `src/index.html` | Hauptfenster |
| `src/diagnostics.html` | Diagnosefenster |
| `src/splash.html` | Start-Splashscreen |
| `time4midi.png` | App-Logo |
| `JHArtworks2023_wh.png` | Brand-Logo |
## Status
Aktueller Paketstand laut `package.json`:
- Version `0.3.3`
Bereits integriert:
- Art-Net
- sACN
- KNX
- MQTT
- OSC
- Audio-Waveform und Beat-Snap
- gelbe Snap-Linien mit einstellbarer Staerke
- Tastatursteuerung fuer Snap-Punkte
- Diagnosefenster
- Splashscreen
## Roadmap / Ideen
- noch praezisere Beat- und Drop-Erkennung
- manuelle Wahl der Snap-Quelle bei mehreren Audio-Tracks
- mehr Audio-Effekte direkt in der Timeline
- Undo / Redo
- Copy / Paste fuer Punkte und Tracks
- Loop-Bereiche und Cue-Strukturen
- bessere Paketierung fuer Release-Builds
## Credits
Entwickelt von `J.H. Artworks`.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+214 -3
View File
@@ -4,14 +4,162 @@ const path = require("node:path");
app.commandLine.appendSwitch("enable-blink-features", "WebMidi"); app.commandLine.appendSwitch("enable-blink-features", "WebMidi");
let mainWindow = null;
let diagnosticsWindow = null;
let splashWindow = null;
const diagnosticsState = {
artnet: { universes: {} },
sacn: { universes: {} },
mqtt: { topics: {} },
knx: { groupAddresses: {} },
recentEvents: []
};
function rememberRecentEvent(entry) {
diagnosticsState.recentEvents.unshift(entry);
diagnosticsState.recentEvents = diagnosticsState.recentEvents.slice(0, 200);
}
function sameChannelValues(left, right) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (Number(left[index] || 0) !== Number(right[index] || 0)) {
return false;
}
}
return true;
}
function updateDmxDiagnostics(transport, record) {
const safeTransport = transport === "artnet" ? "artnet" : "sacn";
const previous = diagnosticsState[safeTransport].universes[String(record.universe)];
const nextValues = Array.isArray(record.values) ? record.values.slice(0, 512) : [];
const changed = !previous || !sameChannelValues(previous.values, nextValues);
diagnosticsState[safeTransport].universes[String(record.universe)] = {
universe: Number(record.universe) || 0,
values: nextValues,
updatedAt: record.timestamp
};
if (changed) {
rememberRecentEvent({
kind: "dmx-frame",
transport: safeTransport,
universe: Number(record.universe) || 0,
activeChannels: nextValues.filter((value) => Number(value) > 0).length,
timestamp: record.timestamp
});
}
}
function updateMqttDiagnostics(record) {
diagnosticsState.mqtt.topics[String(record.topic || "")] = {
topic: String(record.topic || ""),
value: String(record.value ?? ""),
updatedAt: record.timestamp
};
rememberRecentEvent({
kind: "mqtt-publish",
topic: String(record.topic || ""),
value: String(record.value ?? ""),
timestamp: record.timestamp
});
}
function updateKnxDiagnostics(record) {
const key = `${record.groupAddress || ""}:${record.dpt || ""}`;
diagnosticsState.knx.groupAddresses[key] = {
groupAddress: String(record.groupAddress || ""),
dpt: String(record.dpt || ""),
value: record.value,
updatedAt: record.timestamp
};
rememberRecentEvent({
kind: "knx-write",
groupAddress: String(record.groupAddress || ""),
dpt: String(record.dpt || ""),
value: record.value,
timestamp: record.timestamp
});
}
function applyDiagnosticsRecord(record) {
if (!record || typeof record !== "object") {
return;
}
if (record.kind === "dmx-frame") {
updateDmxDiagnostics(record.transport, record);
} else if (record.kind === "mqtt-publish") {
updateMqttDiagnostics(record);
} else if (record.kind === "knx-write") {
updateKnxDiagnostics(record);
}
}
function sendDiagnosticsUpdate(record) {
if (!diagnosticsWindow || diagnosticsWindow.isDestroyed()) {
return;
}
diagnosticsWindow.webContents.send("diagnostics:update", record);
}
function closeSplashWindow() {
if (!splashWindow || splashWindow.isDestroyed()) {
splashWindow = null;
return;
}
splashWindow.close();
splashWindow = null;
}
function createSplashWindow() {
if (splashWindow && !splashWindow.isDestroyed()) {
return splashWindow;
}
splashWindow = new BrowserWindow({
width: 520,
height: 420,
frame: false,
resizable: false,
minimizable: false,
maximizable: false,
movable: true,
show: true,
center: true,
backgroundColor: "#0f1117",
icon: path.join(__dirname, "time4midi.png"),
webPreferences: {
contextIsolation: true,
sandbox: false
}
});
splashWindow.on("closed", () => {
splashWindow = null;
});
splashWindow.loadFile(path.join(__dirname, "src", "splash.html"));
return splashWindow;
}
function createWindow() { function createWindow() {
const win = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1600, width: 1600,
height: 980, height: 980,
minWidth: 1200, minWidth: 1200,
minHeight: 760, minHeight: 760,
backgroundColor: "#0f1117", backgroundColor: "#0f1117",
icon: path.join(__dirname, "time4midi.png"), icon: path.join(__dirname, "time4midi.png"),
show: false,
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
sandbox: false, sandbox: false,
@@ -19,7 +167,49 @@ function createWindow() {
} }
}); });
win.loadFile(path.join(__dirname, "src", "index.html")); mainWindow.on("closed", () => {
mainWindow = null;
});
mainWindow.once("ready-to-show", () => {
setTimeout(() => {
closeSplashWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show();
}
}, 650);
});
mainWindow.loadFile(path.join(__dirname, "src", "index.html"));
}
function createDiagnosticsWindow() {
if (diagnosticsWindow && !diagnosticsWindow.isDestroyed()) {
diagnosticsWindow.focus();
return diagnosticsWindow;
}
diagnosticsWindow = new BrowserWindow({
width: 1100,
height: 820,
minWidth: 900,
minHeight: 640,
backgroundColor: "#0f1117",
icon: path.join(__dirname, "time4midi.png"),
title: "Time4Midi Diagnose",
webPreferences: {
contextIsolation: true,
sandbox: false,
preload: path.join(__dirname, "preload.js")
}
});
diagnosticsWindow.on("closed", () => {
diagnosticsWindow = null;
});
diagnosticsWindow.loadFile(path.join(__dirname, "src", "diagnostics.html"));
return diagnosticsWindow;
} }
ipcMain.handle("project:save", async (_event, payload) => { ipcMain.handle("project:save", async (_event, payload) => {
@@ -119,17 +309,38 @@ ipcMain.handle("mappingLibrary:pickSaveFile", async (_event, payload) => {
}; };
}); });
ipcMain.handle("diagnostics:openWindow", () => {
createDiagnosticsWindow();
return { ok: true };
});
ipcMain.handle("diagnostics:getSnapshot", () => {
return diagnosticsState;
});
ipcMain.on("diagnostics:record", (_event, record) => {
const normalizedRecord = {
...record,
timestamp: Number(record?.timestamp) || Date.now()
};
applyDiagnosticsRecord(normalizedRecord);
sendDiagnosticsUpdate(normalizedRecord);
});
app.whenReady().then(() => { app.whenReady().then(() => {
createSplashWindow();
createWindow(); createWindow();
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (!mainWindow || mainWindow.isDestroyed()) {
createSplashWindow();
createWindow(); createWindow();
} }
}); });
}); });
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
closeSplashWindow();
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit(); app.quit();
} }
+1
View File
@@ -26,6 +26,7 @@
"main.js", "main.js",
"preload.js", "preload.js",
"src/**/*", "src/**/*",
"JHArtworks2023_wh.png",
"time4midi.png", "time4midi.png",
"package.json" "package.json"
], ],
+100 -4
View File
@@ -69,6 +69,28 @@ function addStatusApi(target, handler) {
}; };
} }
function normalizeDiagnosticsChannelValues(values) {
if (!Array.isArray(values)) {
return [];
}
return values.slice(0, 512).map((value) => {
const safeValue = Math.round(Number(value) || 0);
return Math.max(0, Math.min(255, safeValue));
});
}
function recordDiagnostics(record) {
try {
ipcRenderer.send("diagnostics:record", {
...record,
timestamp: Date.now()
});
} catch (_error) {
// Ignore diagnostics forwarding issues to avoid blocking output.
}
}
const artnet = { const artnet = {
...createStatusEmitter(), ...createStatusEmitter(),
sendSocket: null, sendSocket: null,
@@ -297,7 +319,13 @@ function closeUdpSocket(target, key, signatureKey) {
} }
async function ensureUdpSocket(target, key, signatureKey, label, config, options = {}) { async function ensureUdpSocket(target, key, signatureKey, label, config, options = {}) {
const signature = `${config.localAddress || ""}|${options.broadcast ? "1" : "0"}`; const signature = [
config.localAddress || "",
options.broadcast ? "1" : "0",
options.multicast ? "1" : "0",
options.multicastInterface || "",
Number.isFinite(Number(options.multicastTtl)) ? String(Number(options.multicastTtl)) : ""
].join("|");
if (target[key] && target[signatureKey] === signature) { if (target[key] && target[signatureKey] === signature) {
return target[key]; return target[key];
} }
@@ -308,8 +336,14 @@ async function ensureUdpSocket(target, key, signatureKey, label, config, options
target[signatureKey] = signature; target[signatureKey] = signature;
const bindAddress = config.localAddress || "0.0.0.0"; const bindAddress = config.localAddress || "0.0.0.0";
const requiresBinding = Boolean(config.localAddress || options.broadcast); const requiresBinding = Boolean(
config.localAddress
|| options.broadcast
|| options.multicast
|| options.forceBind
);
if (!requiresBinding) { if (!requiresBinding) {
emitStatus(target, "");
return socket; return socket;
} }
@@ -346,6 +380,14 @@ async function ensureUdpSocket(target, key, signatureKey, label, config, options
if (options.broadcast) { if (options.broadcast) {
socket.setBroadcast(true); socket.setBroadcast(true);
} }
if (options.multicast) {
if (Number.isFinite(Number(options.multicastTtl))) {
socket.setMulticastTTL(Number(options.multicastTtl));
}
if (options.multicastInterface) {
socket.setMulticastInterface(options.multicastInterface);
}
}
emitStatus(target, ""); emitStatus(target, "");
return socket; return socket;
@@ -453,6 +495,7 @@ async function sendArtNetDmx(payload) {
const universe = Math.max(0, Math.min(32767, Number(payload.universe) || 0)); const universe = Math.max(0, Math.min(32767, Number(payload.universe) || 0));
const values = Array.isArray(payload.values) ? payload.values : []; const values = Array.isArray(payload.values) ? payload.values : [];
const diagnosticsValues = normalizeDiagnosticsChannelValues(values);
const length = Math.max(2, Math.min(512, values.length || 512)); const length = Math.max(2, Math.min(512, values.length || 512));
const evenLength = length % 2 === 0 ? length : length + 1; const evenLength = length % 2 === 0 ? length : length + 1;
const packet = Buffer.alloc(18 + evenLength); const packet = Buffer.alloc(18 + evenLength);
@@ -487,6 +530,12 @@ async function sendArtNetDmx(payload) {
return; return;
} }
emitStatus(artnet, ""); emitStatus(artnet, "");
recordDiagnostics({
kind: "dmx-frame",
transport: "artnet",
universe,
values: diagnosticsValues
});
resolve(true); resolve(true);
}); });
}); });
@@ -550,8 +599,12 @@ async function sendOsc(payload) {
function configureSacn(nextConfig) { function configureSacn(nextConfig) {
const previousLocalAddress = sacn.config.localAddress; const previousLocalAddress = sacn.config.localAddress;
const previousMulticast = sacn.config.multicast;
sacn.config = normalizeSacnConfig(nextConfig); sacn.config = normalizeSacnConfig(nextConfig);
if (previousLocalAddress !== sacn.config.localAddress) { if (
previousLocalAddress !== sacn.config.localAddress
|| previousMulticast !== sacn.config.multicast
) {
closeUdpSocket(sacn, "socket", "socketSignature"); closeUdpSocket(sacn, "socket", "socketSignature");
} }
emitStatus(sacn, ""); emitStatus(sacn, "");
@@ -588,6 +641,7 @@ async function sendSacnDmx(payload) {
const universe = clampSacnUniverse(payload.universe); const universe = clampSacnUniverse(payload.universe);
const values = Array.isArray(payload.values) ? payload.values : []; const values = Array.isArray(payload.values) ? payload.values : [];
const diagnosticsValues = normalizeDiagnosticsChannelValues(values);
const slotCount = Math.max(1, Math.min(512, values.length || 512)); const slotCount = Math.max(1, Math.min(512, values.length || 512));
const packetLength = 126 + slotCount; const packetLength = 126 + slotCount;
const propertyValueCount = slotCount + 1; const propertyValueCount = slotCount + 1;
@@ -624,7 +678,19 @@ async function sendSacnDmx(payload) {
sacn.sequence = sacn.sequence >= 255 ? 1 : sacn.sequence + 1; sacn.sequence = sacn.sequence >= 255 ? 1 : sacn.sequence + 1;
const host = sacnDestination(universe); const host = sacnDestination(universe);
const socket = await ensureUdpSocket(sacn, "socket", "socketSignature", "sACN", sacn.config); const socket = await ensureUdpSocket(
sacn,
"socket",
"socketSignature",
"sACN",
sacn.config,
{
multicast: sacn.config.multicast,
multicastInterface: sacn.config.localAddress || "",
multicastTtl: 32,
forceBind: sacn.config.multicast
}
);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => { socket.send(packet, 0, packet.length, sacn.config.port, host, (error) => {
@@ -634,6 +700,12 @@ async function sendSacnDmx(payload) {
return; return;
} }
emitStatus(sacn, ""); emitStatus(sacn, "");
recordDiagnostics({
kind: "dmx-frame",
transport: "sacn",
universe,
values: diagnosticsValues
});
resolve(true); resolve(true);
}); });
}); });
@@ -707,6 +779,11 @@ function publishMqtt(payload) {
return; return;
} }
emitStatus(mqttState, ""); emitStatus(mqttState, "");
recordDiagnostics({
kind: "mqtt-publish",
topic: String(payload.topic || ""),
value: String(payload.value ?? "")
});
resolve(true); resolve(true);
}); });
}); });
@@ -1104,6 +1181,12 @@ async function sendKnx(payload) {
return; return;
} }
emitStatus(knxState, ""); emitStatus(knxState, "");
recordDiagnostics({
kind: "knx-write",
groupAddress: String(payload.groupAddress || "1/1/1"),
dpt,
value
});
resolve(true); resolve(true);
}); });
} catch (error) { } catch (error) {
@@ -1134,6 +1217,19 @@ contextBridge.exposeInMainWorld("time4midi", {
importFile: (defaultPath) => importMappingLibrary(defaultPath), importFile: (defaultPath) => importMappingLibrary(defaultPath),
exportFile: (defaultPath, data) => exportMappingLibrary(defaultPath, data) exportFile: (defaultPath, data) => exportMappingLibrary(defaultPath, data)
}, },
diagnostics: {
openWindow: () => ipcRenderer.invoke("diagnostics:openWindow"),
getSnapshot: () => ipcRenderer.invoke("diagnostics:getSnapshot"),
subscribe: (handler) => {
const listener = (_event, record) => {
handler(record);
};
ipcRenderer.on("diagnostics:update", listener);
return () => {
ipcRenderer.removeListener("diagnostics:update", listener);
};
}
},
network: { network: {
listInterfaces: () => listNetworkInterfaces() listInterfaces: () => listNetworkInterfaces()
}, },
+854 -59
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Time4Midi Diagnose</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<main class="diagnostics-shell">
<section class="panel diagnostics-header">
<div class="diagnostics-header-copy">
<div class="eyebrow">Live Monitor</div>
<h1>Diagnose</h1>
<p>Zeigt die letzten gesendeten Werte fuer Art-Net, sACN, MQTT und KNX waehrend die Show laeuft.</p>
</div>
<div class="diagnostics-summary">
<div class="diagnostics-summary-card">
<span class="transport-label">Art-Net Universes</span>
<strong id="artnetUniverseCount">0</strong>
</div>
<div class="diagnostics-summary-card">
<span class="transport-label">sACN Universes</span>
<strong id="sacnUniverseCount">0</strong>
</div>
<div class="diagnostics-summary-card">
<span class="transport-label">MQTT Topics</span>
<strong id="mqttTopicCount">0</strong>
</div>
<div class="diagnostics-summary-card">
<span class="transport-label">KNX Ziele</span>
<strong id="knxValueCount">0</strong>
</div>
<div class="diagnostics-summary-card">
<span class="transport-label">Letztes Update</span>
<strong id="lastDiagnosticsUpdate">-</strong>
</div>
</div>
</section>
<section class="diagnostics-grid">
<section class="panel diagnostics-card">
<div class="panel-header">
<h2>Art-Net</h2>
<span class="diagnostics-transport-pill artnet">DMX</span>
</div>
<div id="artnetDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
</section>
<section class="panel diagnostics-card">
<div class="panel-header">
<h2>sACN</h2>
<span class="diagnostics-transport-pill sacn">DMX</span>
</div>
<div id="sacnDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
</section>
<section class="panel diagnostics-card">
<div class="panel-header">
<h2>MQTT</h2>
<span class="diagnostics-transport-pill mqtt">Topics</span>
</div>
<div id="mqttDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
</section>
<section class="panel diagnostics-card">
<div class="panel-header">
<h2>KNX</h2>
<span class="diagnostics-transport-pill knx">Write</span>
</div>
<div id="knxDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
</section>
<section class="panel diagnostics-card wide">
<div class="panel-header">
<h2>Letzte Events</h2>
</div>
<div id="recentDiagnostics" class="diagnostics-card-body diagnostics-scroll"></div>
</section>
</section>
</main>
<script src="./diagnostics.js"></script>
</body>
</html>
+386
View File
@@ -0,0 +1,386 @@
(function () {
const refs = {
artnetUniverseCount: document.getElementById("artnetUniverseCount"),
sacnUniverseCount: document.getElementById("sacnUniverseCount"),
mqttTopicCount: document.getElementById("mqttTopicCount"),
knxValueCount: document.getElementById("knxValueCount"),
lastDiagnosticsUpdate: document.getElementById("lastDiagnosticsUpdate"),
artnetDiagnostics: document.getElementById("artnetDiagnostics"),
sacnDiagnostics: document.getElementById("sacnDiagnostics"),
mqttDiagnostics: document.getElementById("mqttDiagnostics"),
knxDiagnostics: document.getElementById("knxDiagnostics"),
recentDiagnostics: document.getElementById("recentDiagnostics")
};
const state = {
artnet: { universes: {} },
sacn: { universes: {} },
mqtt: { topics: {} },
knx: { groupAddresses: {} },
recentEvents: []
};
let unsubscribe = null;
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
function sameChannelValues(left, right) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (Number(left[index] || 0) !== Number(right[index] || 0)) {
return false;
}
}
return true;
}
function rememberRecentEvent(entry) {
state.recentEvents.unshift(entry);
state.recentEvents = state.recentEvents.slice(0, 200);
}
function updateDmxDiagnostics(transport, record) {
const safeTransport = transport === "artnet" ? "artnet" : "sacn";
const previous = state[safeTransport].universes[String(record.universe)];
const nextValues = Array.isArray(record.values) ? record.values.slice(0, 512) : [];
const changed = !previous || !sameChannelValues(previous.values, nextValues);
state[safeTransport].universes[String(record.universe)] = {
universe: Number(record.universe) || 0,
values: nextValues,
updatedAt: Number(record.timestamp) || Date.now()
};
if (changed) {
rememberRecentEvent({
kind: "dmx-frame",
transport: safeTransport,
universe: Number(record.universe) || 0,
activeChannels: nextValues.filter((value) => Number(value) > 0).length,
timestamp: Number(record.timestamp) || Date.now()
});
}
}
function updateMqttDiagnostics(record) {
state.mqtt.topics[String(record.topic || "")] = {
topic: String(record.topic || ""),
value: String(record.value ?? ""),
updatedAt: Number(record.timestamp) || Date.now()
};
rememberRecentEvent({
kind: "mqtt-publish",
topic: String(record.topic || ""),
value: String(record.value ?? ""),
timestamp: Number(record.timestamp) || Date.now()
});
}
function updateKnxDiagnostics(record) {
const key = `${record.groupAddress || ""}:${record.dpt || ""}`;
state.knx.groupAddresses[key] = {
groupAddress: String(record.groupAddress || ""),
dpt: String(record.dpt || ""),
value: record.value,
updatedAt: Number(record.timestamp) || Date.now()
};
rememberRecentEvent({
kind: "knx-write",
groupAddress: String(record.groupAddress || ""),
dpt: String(record.dpt || ""),
value: record.value,
timestamp: Number(record.timestamp) || Date.now()
});
}
function applyDiagnosticsRecord(record) {
if (!record || typeof record !== "object") {
return;
}
if (record.kind === "dmx-frame") {
updateDmxDiagnostics(record.transport, record);
return;
}
if (record.kind === "mqtt-publish") {
updateMqttDiagnostics(record);
return;
}
if (record.kind === "knx-write") {
updateKnxDiagnostics(record);
}
}
function replaceState(snapshot) {
state.artnet.universes = { ...(snapshot?.artnet?.universes || {}) };
state.sacn.universes = { ...(snapshot?.sacn?.universes || {}) };
state.mqtt.topics = { ...(snapshot?.mqtt?.topics || {}) };
state.knx.groupAddresses = { ...(snapshot?.knx?.groupAddresses || {}) };
state.recentEvents = Array.isArray(snapshot?.recentEvents) ? snapshot.recentEvents.slice(0, 200) : [];
}
function formatTimestamp(timestamp) {
if (!timestamp) {
return "-";
}
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return "-";
}
return date.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
function formatValue(value) {
if (typeof value === "boolean") {
return value ? "1" : "0";
}
if (typeof value === "number") {
return Number.isInteger(value) ? String(value) : value.toFixed(2);
}
return String(value ?? "");
}
function getActiveChannels(values) {
const channels = [];
(values || []).forEach((value, index) => {
const safeValue = Number(value) || 0;
if (safeValue > 0) {
channels.push({
channel: index + 1,
value: safeValue
});
}
});
return channels;
}
function renderDmxContainer(container, universes, emptyMessage) {
const entries = Object.values(universes || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
if (!entries.length) {
container.innerHTML = `<div class="diagnostics-empty">${escapeHtml(emptyMessage)}</div>`;
return;
}
container.innerHTML = `
<div class="diagnostics-list">
${entries.map((entry) => {
const activeChannels = getActiveChannels(entry.values);
const visibleChannels = activeChannels.slice(0, 48);
const hiddenCount = Math.max(0, activeChannels.length - visibleChannels.length);
return `
<article class="diagnostics-entry">
<div class="diagnostics-entry-header">
<strong>Universe ${entry.universe}</strong>
<span>${activeChannels.length} aktive Kanaele</span>
</div>
<div class="diagnostics-entry-meta">
<span>Letztes Update ${formatTimestamp(entry.updatedAt)}</span>
</div>
${visibleChannels.length ? `
<div class="diagnostics-channel-grid">
${visibleChannels.map((channel) => `
<div class="diagnostics-channel-chip diagnostics-mono">Ch ${channel.channel}: ${channel.value}</div>
`).join("")}
</div>
${hiddenCount > 0 ? `<div class="diagnostics-entry-meta"><span>+ ${hiddenCount} weitere Kanaele</span></div>` : ""}
` : `
<div class="diagnostics-entry-meta">
<span>Alle Kanaele aktuell auf 0</span>
</div>
`}
</article>
`;
}).join("")}
</div>
`;
}
function renderMqttContainer() {
const entries = Object.values(state.mqtt.topics || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
if (!entries.length) {
refs.mqttDiagnostics.innerHTML = `<div class="diagnostics-empty">Noch keine MQTT-Nachrichten gesendet.</div>`;
return;
}
refs.mqttDiagnostics.innerHTML = `
<div class="diagnostics-list">
${entries.map((entry) => `
<article class="diagnostics-entry">
<div class="diagnostics-entry-header">
<strong class="diagnostics-mono">${escapeHtml(entry.topic)}</strong>
<span>${formatTimestamp(entry.updatedAt)}</span>
</div>
<div class="diagnostics-entry-meta">
<span>Wert</span>
<strong class="diagnostics-mono">${escapeHtml(formatValue(entry.value))}</strong>
</div>
</article>
`).join("")}
</div>
`;
}
function renderKnxContainer() {
const entries = Object.values(state.knx.groupAddresses || {}).sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
if (!entries.length) {
refs.knxDiagnostics.innerHTML = `<div class="diagnostics-empty">Noch keine KNX-Schreibvorgaenge gesendet.</div>`;
return;
}
refs.knxDiagnostics.innerHTML = `
<div class="diagnostics-list">
${entries.map((entry) => `
<article class="diagnostics-entry">
<div class="diagnostics-entry-header">
<strong class="diagnostics-mono">${escapeHtml(entry.groupAddress)}</strong>
<span>${escapeHtml(entry.dpt)}</span>
</div>
<div class="diagnostics-entry-meta">
<span>Wert ${escapeHtml(formatValue(entry.value))}</span>
<span>${formatTimestamp(entry.updatedAt)}</span>
</div>
</article>
`).join("")}
</div>
`;
}
function describeRecentEvent(entry) {
if (entry.kind === "dmx-frame") {
return `
<article class="diagnostics-entry">
<div class="diagnostics-entry-header">
<strong>${escapeHtml(entry.transport === "artnet" ? "Art-Net" : "sACN")} Universe ${escapeHtml(entry.universe)}</strong>
<span class="diagnostics-transport-pill ${escapeHtml(entry.transport)}">${escapeHtml(entry.transport)}</span>
</div>
<div class="diagnostics-entry-meta">
<span>${escapeHtml(entry.activeChannels)} aktive Kanaele</span>
<span>${formatTimestamp(entry.timestamp)}</span>
</div>
</article>
`;
}
if (entry.kind === "mqtt-publish") {
return `
<article class="diagnostics-entry">
<div class="diagnostics-entry-header">
<strong class="diagnostics-mono">${escapeHtml(entry.topic)}</strong>
<span class="diagnostics-transport-pill mqtt">mqtt</span>
</div>
<div class="diagnostics-entry-meta">
<span>Wert ${escapeHtml(formatValue(entry.value))}</span>
<span>${formatTimestamp(entry.timestamp)}</span>
</div>
</article>
`;
}
if (entry.kind === "knx-write") {
return `
<article class="diagnostics-entry">
<div class="diagnostics-entry-header">
<strong class="diagnostics-mono">${escapeHtml(entry.groupAddress)}</strong>
<span class="diagnostics-transport-pill knx">${escapeHtml(entry.dpt)}</span>
</div>
<div class="diagnostics-entry-meta">
<span>Wert ${escapeHtml(formatValue(entry.value))}</span>
<span>${formatTimestamp(entry.timestamp)}</span>
</div>
</article>
`;
}
return "";
}
function renderRecentEvents() {
const entries = state.recentEvents.slice(0, 80);
if (!entries.length) {
refs.recentDiagnostics.innerHTML = `<div class="diagnostics-empty">Noch keine Diagnose-Events vorhanden.</div>`;
return;
}
refs.recentDiagnostics.innerHTML = `
<div class="diagnostics-history">
${entries.map((entry) => describeRecentEvent(entry)).join("")}
</div>
`;
}
function renderSummary() {
const universeCounts = {
artnet: Object.keys(state.artnet.universes || {}).length,
sacn: Object.keys(state.sacn.universes || {}).length,
mqtt: Object.keys(state.mqtt.topics || {}).length,
knx: Object.keys(state.knx.groupAddresses || {}).length
};
const timestamps = [
...Object.values(state.artnet.universes || {}).map((entry) => entry.updatedAt || 0),
...Object.values(state.sacn.universes || {}).map((entry) => entry.updatedAt || 0),
...Object.values(state.mqtt.topics || {}).map((entry) => entry.updatedAt || 0),
...Object.values(state.knx.groupAddresses || {}).map((entry) => entry.updatedAt || 0)
];
const lastUpdate = timestamps.length ? Math.max(...timestamps) : 0;
refs.artnetUniverseCount.textContent = String(universeCounts.artnet);
refs.sacnUniverseCount.textContent = String(universeCounts.sacn);
refs.mqttTopicCount.textContent = String(universeCounts.mqtt);
refs.knxValueCount.textContent = String(universeCounts.knx);
refs.lastDiagnosticsUpdate.textContent = formatTimestamp(lastUpdate);
}
function renderAll() {
renderSummary();
renderDmxContainer(refs.artnetDiagnostics, state.artnet.universes, "Noch keine Art-Net Frames gesendet.");
renderDmxContainer(refs.sacnDiagnostics, state.sacn.universes, "Noch keine sACN Frames gesendet.");
renderMqttContainer();
renderKnxContainer();
renderRecentEvents();
}
async function init() {
if (!window.time4midi?.diagnostics) {
document.body.innerHTML = `<main class="diagnostics-shell"><section class="panel"><div class="diagnostics-empty">Diagnose-Bridge nicht verfuegbar. Bitte App neu starten.</div></section></main>`;
return;
}
const snapshot = await window.time4midi.diagnostics.getSnapshot();
replaceState(snapshot);
renderAll();
unsubscribe = window.time4midi.diagnostics.subscribe((record) => {
applyDiagnosticsRecord(record);
renderAll();
});
}
window.addEventListener("beforeunload", () => {
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
});
void init();
})();
+34
View File
@@ -86,7 +86,40 @@
<span class="transport-label">Status</span> <span class="transport-label">Status</span>
<strong id="transportStatusLabel">Idle</strong> <strong id="transportStatusLabel">Idle</strong>
</div> </div>
<label class="transport-block transport-field">
<span class="transport-label">Timeline Laenge</span>
<input id="timelineDurationInput" type="number" min="1" max="86400" step="1" value="120">
</label>
<label class="transport-block transport-checkbox">
<span class="transport-label">Beat Snap</span>
<input id="beatSnapEnabledInput" type="checkbox">
</label>
<label class="transport-block transport-field">
<span class="transport-label">Snap Raster</span>
<select id="beatSnapGridSelect">
<option value="0.25">1/4 Beat</option>
<option value="0.5">1/2 Beat</option>
<option value="1" selected>1 Beat</option>
<option value="2">2 Beats</option>
<option value="4">1 Takt</option>
<option value="8">2 Takte</option>
</select>
</label>
<label class="transport-block transport-field transport-field-small">
<span class="transport-label">Linien</span>
<select id="beatSnapLineWidthSelect">
<option value="1">Duenn</option>
<option value="2" selected>Mittel</option>
<option value="3">Dick</option>
<option value="4">Extra</option>
</select>
</label>
<div class="transport-block transport-source">
<span class="transport-label">Snap Quelle</span>
<strong id="beatSnapSourceLabel">Kein Audio-Raster</strong>
</div>
<div class="transport-controls"> <div class="transport-controls">
<button id="openDiagnosticsButton" class="ghost">Diagnose</button>
<button id="openSettingsButton" class="ghost">Einstellungen</button> <button id="openSettingsButton" class="ghost">Einstellungen</button>
<button id="playButton">Play</button> <button id="playButton">Play</button>
<button id="pauseButton" class="ghost">Pause</button> <button id="pauseButton" class="ghost">Pause</button>
@@ -104,6 +137,7 @@
<div class="editor-hints"> <div class="editor-hints">
<span>Doppelklick: Punkt setzen</span> <span>Doppelklick: Punkt setzen</span>
<span>Ziehen: Zeit/Wert aendern</span> <span>Ziehen: Zeit/Wert aendern</span>
<span>Audio ziehen: Clip verschieben</span>
<span>Strg+Mausrad: Zoom</span> <span>Strg+Mausrad: Zoom</span>
<span>Mausrad: horizontal scrollen</span> <span>Mausrad: horizontal scrollen</span>
<span>Entf: Punkt loeschen</span> <span>Entf: Punkt loeschen</span>
+168
View File
@@ -0,0 +1,168 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Time4Midi startet</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0f15;
--panel: rgba(18, 24, 34, 0.96);
--line: rgba(255, 255, 255, 0.08);
--text: #ecf2ff;
--muted: #9ca8bf;
--accent: #6ec2f2;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(110, 194, 242, 0.2), transparent 34%),
radial-gradient(circle at bottom right, rgba(110, 232, 200, 0.14), transparent 28%),
linear-gradient(180deg, #09111b 0%, #101a28 100%);
color: var(--text);
font-family: "Trebuchet MS", "Segoe UI Variable Text", "Aptos", sans-serif;
}
body {
display: grid;
place-items: center;
}
.shell {
width: 100%;
height: 100%;
padding: 28px;
}
.panel {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 24px;
padding: 28px;
border-radius: 24px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
}
.brand-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.brand-row img:first-child {
width: 156px;
height: auto;
object-fit: contain;
}
.brand-row img:last-child {
width: 132px;
height: auto;
object-fit: contain;
opacity: 0.95;
}
.copy {
display: flex;
flex-direction: column;
gap: 8px;
}
.eyebrow {
font-size: 0.68rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
}
h1 {
margin: 0;
font-size: 1.8rem;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.45;
}
.loader {
display: grid;
gap: 10px;
}
.loader-bar {
overflow: hidden;
height: 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.loader-bar::before {
content: "";
display: block;
width: 38%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, rgba(110, 194, 242, 0.25), rgba(110, 194, 242, 0.95), rgba(142, 232, 200, 0.55));
animation: loading 1.2s ease-in-out infinite;
}
.loader-meta {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 0.8rem;
}
@keyframes loading {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(290%);
}
}
</style>
</head>
<body>
<main class="shell">
<section class="panel">
<div class="brand-row">
<img src="../time4midi.png" alt="Time4Midi">
<img src="../JHArtworks2023_wh.png" alt="J.H. Artworks">
</div>
<div class="copy">
<div class="eyebrow">Startup</div>
<h1>Time4Midi startet</h1>
<p>Die Engine, Treiber und das Projekt-Setup werden vorbereitet. Gleich geht es los.</p>
</div>
<div class="loader">
<div class="loader-bar" aria-hidden="true"></div>
<div class="loader-meta">
<span>Bitte kurz warten</span>
<span>Renderer wird geladen</span>
</div>
</div>
</section>
</main>
</body>
</html>
+197
View File
@@ -180,6 +180,43 @@ p {
flex-wrap: wrap; flex-wrap: wrap;
} }
.transport-field {
min-width: 132px;
max-width: 156px;
}
.transport-field-small {
min-width: 96px;
max-width: 112px;
}
.transport-field input {
padding: 0.42rem 0.6rem;
}
.transport-field select {
padding: 0.42rem 0.6rem;
}
.transport-checkbox {
min-width: 90px;
align-content: end;
}
.transport-checkbox input {
width: 16px;
height: 16px;
}
.transport-source {
min-width: 150px;
}
.transport-source strong {
font-size: 0.78rem;
line-height: 1.3;
}
.editor-hints span, .editor-hints span,
.meta-row span, .meta-row span,
.transport-label { .transport-label {
@@ -704,3 +741,163 @@ p {
padding: 14px; padding: 14px;
} }
} }
.diagnostics-shell {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 100vh;
padding: 12px;
}
.diagnostics-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.diagnostics-header-copy {
display: flex;
flex-direction: column;
gap: 6px;
}
.diagnostics-summary {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.diagnostics-summary-card {
min-width: 110px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.diagnostics-summary-card strong {
display: block;
font-size: 1.1rem;
margin-top: 4px;
}
.diagnostics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.diagnostics-card {
display: flex;
flex-direction: column;
min-height: 240px;
}
.diagnostics-card.wide {
grid-column: 1 / -1;
}
.diagnostics-card-body {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.diagnostics-scroll {
overflow: auto;
min-height: 0;
}
.diagnostics-list,
.diagnostics-history {
display: flex;
flex-direction: column;
gap: 8px;
}
.diagnostics-entry {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.diagnostics-entry-header,
.diagnostics-entry-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.diagnostics-entry-meta {
margin-top: 6px;
color: var(--muted);
font-size: 0.76rem;
}
.diagnostics-channel-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 6px;
margin-top: 8px;
}
.diagnostics-channel-chip {
padding: 0.45rem 0.55rem;
border-radius: 10px;
background: rgba(110, 194, 242, 0.1);
border: 1px solid rgba(110, 194, 242, 0.18);
font-size: 0.8rem;
}
.diagnostics-transport-pill {
display: inline-flex;
align-items: center;
padding: 0.22rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text);
}
.diagnostics-transport-pill.artnet {
background: rgba(255, 179, 92, 0.18);
border: 1px solid rgba(255, 179, 92, 0.28);
}
.diagnostics-transport-pill.sacn {
background: rgba(110, 194, 242, 0.18);
border: 1px solid rgba(110, 194, 242, 0.28);
}
.diagnostics-transport-pill.mqtt,
.diagnostics-transport-pill.knx {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.diagnostics-empty {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.45;
}
.diagnostics-mono {
font-family: "Consolas", "Cascadia Mono", "Courier New", monospace;
}
@media (max-width: 980px) {
.diagnostics-grid {
grid-template-columns: 1fr;
}
.diagnostics-header {
flex-direction: column;
}
}