Update docs and screenshots
CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s

This commit is contained in:
2026-07-25 10:26:29 +02:00
parent d6cda77aaf
commit 1f110866f5
170 changed files with 24657 additions and 3 deletions
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TuxDMX</title>
<style>
html, body, #root {
margin: 0;
min-height: 100%;
background: #080b12;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1280
View File
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
import { ReactNode, useEffect, useMemo, useState } from "react";
type AppShellProps = {
currentPage: string;
sections: Array<{
id: string;
label: string;
icon: SectionIconName;
tone?: "accent" | "muted";
items: Array<{ id: string; label: string; icon: IconName }>;
}>;
onNavigate: (page: string) => void;
children: ReactNode;
};
type IconName =
| "dashboard"
| "live"
| "scenes"
| "effects"
| "bpm"
| "fixtures"
| "patch"
| "placement"
| "mixitup"
| "midi"
| "monitor"
| "telemetry"
| "backup"
| "settings";
type SectionIconName = "overview" | "live-control" | "lighting-setup" | "integrations" | "monitoring" | "system";
function NavIcon({ name }: { name: IconName }) {
const commonProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
switch (name) {
case "dashboard":
return (
<svg {...commonProps}>
<path d="M4 5h7v6H4zM13 5h7v10h-7zM4 13h7v6H4zM13 17h7v2h-7z" />
</svg>
);
case "live":
return (
<svg {...commonProps}>
<path d="M4 16a8 8 0 0 1 16 0" />
<path d="M7 16a5 5 0 0 1 10 0" />
<path d="M10.5 16a1.5 1.5 0 0 1 3 0" />
<circle cx="12" cy="16" r="1.2" fill="currentColor" stroke="none" />
</svg>
);
case "scenes":
return (
<svg {...commonProps}>
<path d="M4 7h16M4 12h16M4 17h10" />
<circle cx="18" cy="17" r="2" />
</svg>
);
case "effects":
return (
<svg {...commonProps}>
<path d="M13 2 5 14h6l-1 8 8-12h-6z" />
</svg>
);
case "bpm":
return (
<svg {...commonProps}>
<path d="M5 12h2l2-4 4 8 2-4h4" />
</svg>
);
case "fixtures":
return (
<svg {...commonProps}>
<path d="M8 4h8v4H8zM6 8h12v10H6zM10 12h4M10 16h4" />
</svg>
);
case "patch":
return (
<svg {...commonProps}>
<path d="M7 7h4v4H7zM13 13h4v4h-4z" />
<path d="M11 9h2m-6 6h2m4-4 2-2m-6 6 2-2" />
</svg>
);
case "placement":
return (
<svg {...commonProps}>
<path d="M12 21s6-5.2 6-11a6 6 0 1 0-12 0c0 5.8 6 11 6 11Z" />
<circle cx="12" cy="10" r="2" />
</svg>
);
case "mixitup":
return (
<svg {...commonProps}>
<path d="M4 8h8M4 16h12M16 8h4M10 16h2" />
<circle cx="14" cy="8" r="2" />
<circle cx="8" cy="16" r="2" />
</svg>
);
case "midi":
return (
<svg {...commonProps}>
<path d="M6 5h8v12a3 3 0 1 1-3-3h1" />
<path d="M14 8h4v8a3 3 0 1 1-3-3h1" />
</svg>
);
case "monitor":
return (
<svg {...commonProps}>
<rect x="3" y="5" width="18" height="12" rx="2" />
<path d="M8 20h8M10 17v3M14 17v3" />
</svg>
);
case "telemetry":
return (
<svg {...commonProps}>
<path d="M5 18V9M10 18V5M15 18v-7M20 18v-3" />
</svg>
);
case "backup":
return (
<svg {...commonProps}>
<path d="M5 8h14v10H5z" />
<path d="M9 8V5h6v3M12 12v3M10.5 13.5 12 15l1.5-1.5" />
</svg>
);
case "settings":
return (
<svg {...commonProps}>
<circle cx="12" cy="12" r="3" />
<path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.5-2.3.8a7 7 0 0 0-1.7-1L14.5 3h-5l-.4 2.8a7 7 0 0 0-1.7 1L5 6 3 9.5 5 11a7 7 0 0 0 0 2l-2 1.5L5 18l2.4-.8a7 7 0 0 0 1.7 1l.4 2.8h5l.4-2.8a7 7 0 0 0 1.7-1l2.3.8 2-3.5L18.9 13c.1-.3.1-.7.1-1Z" />
</svg>
);
}
}
function SectionIcon({ name }: { name: SectionIconName }) {
const commonProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
switch (name) {
case "overview":
return (
<svg {...commonProps}>
<path d="M4 5h7v6H4zM13 5h7v6h-7zM4 13h7v6H4zM13 13h7v6h-7z" />
</svg>
);
case "live-control":
return (
<svg {...commonProps}>
<path d="M4 16a8 8 0 0 1 16 0" />
<path d="M7 16a5 5 0 0 1 10 0" />
<path d="M10.5 16a1.5 1.5 0 0 1 3 0" />
<circle cx="12" cy="16" r="1.2" fill="currentColor" stroke="none" />
</svg>
);
case "lighting-setup":
return (
<svg {...commonProps}>
<path d="M8 4h8v4H8zM6 8h12v10H6zM10 12h4M10 16h4" />
</svg>
);
case "integrations":
return (
<svg {...commonProps}>
<path d="M8 8h3v3H8zM13 13h3v3h-3z" />
<path d="m11 9 2-2m-4 8 2-2m2 0 2 2m-6-6 2 2" />
</svg>
);
case "monitoring":
return (
<svg {...commonProps}>
<path d="M5 18V9M10 18V5M15 18v-7M20 18v-3" />
</svg>
);
case "system":
return (
<svg {...commonProps}>
<circle cx="12" cy="12" r="3" />
<path d="M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.5-2.3.8a7 7 0 0 0-1.7-1L14.5 3h-5l-.4 2.8a7 7 0 0 0-1.7 1L5 6 3 9.5 5 11a7 7 0 0 0 0 2l-2 1.5L5 18l2.4-.8a7 7 0 0 0 1.7 1l.4 2.8h5l.4-2.8a7 7 0 0 0 1.7-1l2.3.8 2-3.5L18.9 13c.1-.3.1-.7.1-1Z" />
</svg>
);
}
}
export function AppShell({ currentPage, sections, onNavigate, children }: AppShellProps) {
const activeSectionId = useMemo(
() => sections.find((section) => section.items.some((item) => item.id === currentPage))?.id ?? sections[0]?.id ?? "",
[currentPage, sections]
);
const [openSectionId, setOpenSectionId] = useState(activeSectionId);
useEffect(() => {
setOpenSectionId(activeSectionId);
}, [activeSectionId]);
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<h1>TuxDMX</h1>
<p>Headless lysstyring med simulator, ærlig telemetri og Twitch-klar API.</p>
</div>
<nav className="nav-list" aria-label="Primær navigation">
{sections.map((section) => (
<section
key={section.id}
className={`nav-section nav-section-${section.tone ?? "muted"} ${openSectionId === section.id ? "open" : ""}`}
>
<button
type="button"
className={`nav-section-trigger ${openSectionId === section.id ? "active" : ""}`}
aria-expanded={openSectionId === section.id}
onClick={() => setOpenSectionId((current) => (current === section.id ? activeSectionId : section.id))}
>
<span className="nav-section-trigger-main">
<span className="nav-section-icon">
<SectionIcon name={section.icon} />
</span>
<span className="nav-section-copy">
<span className="nav-section-label">{section.label}</span>
<span className="nav-section-meta">{section.items.length} sider</span>
</span>
</span>
<span className="nav-section-chevron" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="m8 10 4 4 4-4" />
</svg>
</span>
</button>
{openSectionId === section.id ? (
<div className="nav-section-items">
{section.items.map((page) => (
<button
key={page.id}
className={`nav-button ${currentPage === page.id ? "active" : ""}`}
onClick={() => onNavigate(page.id)}
>
<span className="nav-button-icon">
<NavIcon name={page.icon} />
</span>
<span className="nav-button-label">{page.label}</span>
</button>
))}
</div>
) : null}
</section>
))}
</nav>
</aside>
<main className="content">{children}</main>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
type ChannelGridProps = {
values: number[];
sources: string[];
};
export function ChannelGrid({ values, sources }: ChannelGridProps) {
const activeChannels = values
.map((value, index) => ({
channel: index + 1,
value,
source: sources[index] ?? "idle"
}))
.filter((entry) => entry.value > 0 || entry.source !== "idle");
if (activeChannels.length === 0) {
return (
<div className="event-item">
<strong>Ingen aktive DMX-kanaler</strong>
<div className="muted">Monitoren viser kun kanaler med værdi over 0 eller aktiv kilde.</div>
</div>
);
}
return (
<div className="channel-grid" aria-label="DMX kanalgrid">
{activeChannels.map((entry) => (
<div
key={entry.channel}
className="channel-cell"
style={{
background: `linear-gradient(180deg, rgba(0,229,255,${Math.max(
0.08,
entry.value / 255
)}), rgba(255,61,242,0.06))`
}}
title={`Kanal ${entry.channel} • Kilde ${entry.source}`}
>
<span>{entry.channel}</span>
<strong>{entry.value}</strong>
<small>{entry.source.replace(/^.*:/, "")}</small>
</div>
))}
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
type StatusPillProps = {
label: string;
tone: "success" | "warning" | "danger" | "info";
};
export function StatusPill({ label, tone }: StatusPillProps) {
return <span className={`pill ${tone}`}>{label}</span>;
}
+604
View File
@@ -0,0 +1,604 @@
export type SystemStatus = {
app: string;
setup_required: boolean;
engine: {
backend: string;
connected: boolean;
degraded: boolean;
last_error: string | null;
blackout: boolean;
fps: number;
master: number;
frame: number[];
source_map: string[];
updated_at?: string | null;
};
telemetry: {
uptime_seconds: number;
cpu_percent: number;
ram_percent: number;
temperature_c: number | null;
queue_depth: number;
reconnect_count: number;
last_error: string | null;
events: Array<{ created_at: string; category: string; level: string; message: string }>;
};
bpm: BpmStatus;
};
export type DmxOutputConfig = {
backend: "simulator" | "ola" | "artnet";
universe: number;
output_port: string;
target_host: string;
artnet_nodes: ArtNetNode[];
};
export type DmxDevice = {
name: string;
backend: string;
connected: boolean;
output_port: string | null;
universe: number;
};
export type HomeAssistantConfig = {
enabled: boolean;
base_url: string;
default_universe: number;
has_token: boolean;
token_mask: string;
mapping_count: number;
dispatch_count: number;
error_count: number;
last_error: string | null;
last_successful_call_at: string | null;
last_connection_success_at: string | null;
last_connection_error: string | null;
ha_version: string | null;
auth_ok: boolean;
reachable: boolean;
};
export type HomeAssistantConnectionResult = {
reachable: boolean;
auth_ok: boolean;
ha_version: string | null;
last_error: string | null;
last_successful_call_at: string | null;
last_connection_success_at: string | null;
};
export type HomeAssistantEntity = {
entity_id: string;
domain: string;
friendly_name: string;
state: string;
};
export type HomeAssistantMapping = {
id: number;
name: string;
universe: number;
start_address: number;
fixture_type: "dimmer" | "rgb" | "rgbw" | "cct" | "switch" | "scene" | "automation";
entity_id: string;
rate_limit_hz: number;
deadband: number;
fade_ms: number;
invert_channel: boolean;
min_value: number;
max_value: number;
enabled: boolean;
master_dimmer: boolean;
channel_span: number;
status: string;
last_dmx_values: number[];
last_sent_summary: string | null;
last_service: string | null;
last_success_at: string | null;
last_error: string | null;
last_error_at: string | null;
in_flight: boolean;
};
export type MidiBridgeStatus = {
bridge_id: string;
device_name: string;
ip_address: string | null;
protocol_version: number;
online: boolean;
last_heartbeat_at: string | null;
last_event_at: string | null;
last_error: string | null;
last_event: {
type: string;
channel: number;
number: number;
value: number;
} | null;
updated_at: string;
created_at: string;
};
export type MidiMapping = {
id: number;
name: string;
enabled: boolean;
bridge_id: string | null;
device_name: string | null;
message_type: "note_on" | "note_off" | "control_change" | "program_change";
channel: number | null;
number: number;
action:
| "activate_scene"
| "deactivate_scene"
| "toggle_scene"
| "flash_scene"
| "blackout_on"
| "blackout_off"
| "blackout_toggle"
| "set_master_dimmer"
| "set_scene_intensity"
| "start_effect"
| "stop_effect";
target_type: "scene" | "effect" | "system" | "global";
target_id: string | null;
mode: "trigger" | "toggle" | "hold" | "flash" | "continuous";
minimum_value: number;
maximum_value: number;
created_at: string;
updated_at: string;
active: boolean;
};
export type MidiBridgeToken = {
id: number;
label: string;
bridge_id: string | null;
scopes: string[];
created_at: string;
revoked_at: string | null;
last_used_at: string | null;
token_preview: string;
token?: string;
};
export type MidiLearnState = {
active: boolean;
allow_passthrough: boolean;
expires_at: string | null;
captured_event: {
bridge_id: string;
device: string;
timestamp: string;
message: {
type: string;
channel: number;
number: number;
value: number;
};
} | null;
};
export type ArtNetNode = {
ip: string;
short_name: string;
long_name: string;
label: string;
net: number;
sub_switch: number;
port_count: number;
raw_port_address: number;
};
export type BpmDevice = {
id: string;
name: string;
backend: string;
is_default: boolean;
recommended?: boolean;
};
export type BpmStatus = {
mode: string;
bpm: number;
confidence: number;
devices: string[];
device_details?: BpmDevice[];
current_device: string;
selected_device: string;
recommended_device: string;
audio_connected: boolean;
last_error: string | null;
last_beat_at?: number | null;
input_level: number;
peak_level: number;
clipping: boolean;
sample_rate: number;
channels: number;
format: string;
};
export type BpmConfig = {
preferred_device: string;
recommended_device: string;
sample_rate: number;
channels: number;
format: string;
};
export type FixturePreview = {
manufacturer: string;
model: string;
categories: string[];
schema_version: string;
modes: Array<{
key: string;
channel_count: number;
channels: Array<{
index: number;
key: string;
display_name: string;
precedence: string;
resolution: number;
}>;
}>;
};
export type FixtureSummary = {
id: number;
slug: string;
manufacturer: string;
model: string;
short_name: string | null;
categories: string[];
schema_version: string;
source: {
manufacturer_key?: string;
fixture_key?: string;
};
warnings: string[];
modes: Array<{
key: string;
channel_count: number;
channels: Array<{
index: number;
key: string;
display_name: string;
precedence: string;
resolution: number;
capabilities?: unknown[];
}>;
}>;
};
export type PatchItem = {
id: number;
universe: number;
name: string;
definition_id: number;
fixture_slug: string | null;
manufacturer: string | null;
model: string | null;
mode_key: string;
start_address: number;
end_address: number;
channel_count: number;
enabled: boolean;
group_names: string[];
position: {
x: number;
y: number;
z: number;
rotation: number;
};
channels: Array<{
index: number;
key: string;
display_name: string;
precedence: string;
resolution: number;
}>;
};
export type SceneItem = {
id: number;
name: string;
slug: string;
color: string;
icon: string | null;
priority: number;
fade_in_ms: number;
fade_out_ms: number;
hold_ms: number;
master_limit: number;
values: Array<{
channel: number;
value: number;
precedence: string;
source?: string;
}>;
targets: Array<{
target_type: "fixture" | "group";
patch_id?: number | null;
group_name?: string | null;
values: Array<{
attribute: string;
value: number;
precedence?: string | null;
}>;
}>;
tags: string[];
is_active: boolean;
};
export type PatchValidation = {
valid: boolean;
range: string;
end_address: number;
channel_count: number;
occupied_channels: number[];
conflicts: Array<{
type: string;
patch_id?: number;
name?: string;
range?: string;
fixture?: string;
message: string;
}>;
};
export type SystemCommandResult = {
accepted: boolean;
action: string;
status: string;
detail: string;
};
export type LiveMixerItem = {
id: string;
source_type: "patch" | "home_assistant";
source_id: number;
patch_id: number | null;
name: string;
manufacturer: string | null;
model: string | null;
mode_key: string;
universe: number;
start_address: number;
end_address: number;
channel_count: number;
entity_id?: string | null;
status?: string | null;
last_sent_summary?: string | null;
channels: Array<{
index: number;
absolute_channel: number;
key: string;
display_name: string;
precedence: string;
resolution: number;
value: number;
}>;
};
const jsonHeaders = { "Content-Type": "application/json" };
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api/v1${path}`, init);
if (!response.ok) {
throw new Error(`API-fejl ${response.status}`);
}
return (await response.json()) as T;
}
export const api = {
getStatus: () => request<SystemStatus>("/system/status"),
restartService: () => request<SystemCommandResult>("/system/restart-service", { method: "POST" }),
rebootHost: () => request<SystemCommandResult>("/system/reboot-host", { method: "POST" }),
getDmxConfig: () => request<DmxOutputConfig>("/dmx/config"),
saveDmxConfig: (payload: Record<string, unknown>) =>
request<DmxOutputConfig>("/dmx/config", {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
discoverArtNet: (timeoutS = 1) =>
request<{ items: ArtNetNode[]; count: number }>(`/dmx/artnet/discover?timeout_s=${timeoutS}`, {
method: "POST"
}),
listDmxDevices: () => request<{ devices: DmxDevice[] }>("/dmx/devices"),
getHomeAssistantConfig: () => request<HomeAssistantConfig>("/integrations/home-assistant/config"),
saveHomeAssistantConfig: (payload: Record<string, unknown>) =>
request<HomeAssistantConfig>("/integrations/home-assistant/config", {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
testHomeAssistantConnection: () =>
request<HomeAssistantConnectionResult>("/integrations/home-assistant/test-connection", {
method: "POST"
}),
listHomeAssistantEntities: () =>
request<{ items: HomeAssistantEntity[] }>("/integrations/home-assistant/entities"),
listHomeAssistantMappings: () =>
request<{ items: HomeAssistantMapping[] }>("/integrations/home-assistant/mappings"),
createHomeAssistantMapping: (payload: Record<string, unknown>) =>
request<HomeAssistantMapping>("/integrations/home-assistant/mappings", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
updateHomeAssistantMapping: (mappingId: number, payload: Record<string, unknown>) =>
request<HomeAssistantMapping>(`/integrations/home-assistant/mappings/${mappingId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
deleteHomeAssistantMapping: (mappingId: number) =>
request<Record<string, unknown>>(`/integrations/home-assistant/mappings/${mappingId}`, { method: "DELETE" }),
testHomeAssistantMapping: (mappingId: number) =>
request<Record<string, unknown>>(`/integrations/home-assistant/mappings/${mappingId}/test`, {
method: "POST"
}),
listMidiBridges: () => request<{ items: MidiBridgeStatus[] }>("/integrations/midi/bridges"),
listMidiMappings: () => request<{ items: MidiMapping[] }>("/integrations/midi/mappings"),
createMidiMapping: (payload: Record<string, unknown>) =>
request<MidiMapping>("/integrations/midi/mappings", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
updateMidiMapping: (mappingId: number, payload: Record<string, unknown>) =>
request<MidiMapping>(`/integrations/midi/mappings/${mappingId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
deleteMidiMapping: (mappingId: number) =>
request<Record<string, unknown>>(`/integrations/midi/mappings/${mappingId}`, { method: "DELETE" }),
testMidiMapping: (mappingId: number, value = 127) =>
request<Record<string, unknown>>(`/integrations/midi/mappings/${mappingId}/test`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ value })
}),
listMidiTokens: () => request<{ items: MidiBridgeToken[] }>("/integrations/midi/tokens"),
createMidiToken: (payload: Record<string, unknown>) =>
request<MidiBridgeToken>("/integrations/midi/tokens", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
revokeMidiToken: (tokenId: number) =>
request<Record<string, unknown>>(`/integrations/midi/tokens/${tokenId}`, { method: "DELETE" }),
getMidiLearnState: () => request<MidiLearnState>("/integrations/midi/learn"),
startMidiLearn: (timeoutSeconds = 15, allowPassthrough = false) =>
request<MidiLearnState>("/integrations/midi/learn/start", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ timeout_seconds: timeoutSeconds, allow_passthrough: allowPassthrough })
}),
cancelMidiLearn: () =>
request<MidiLearnState>("/integrations/midi/learn/cancel", {
method: "POST"
}),
getFrame: () => request<{ universe: number; values: number[]; sources: string[] }>("/dmx/frame"),
getFixtures: () => request<{ items: FixtureSummary[] }>("/fixtures"),
searchFixtures: (query: string) =>
request<{ results: Array<{ fixture_key: string; cached: boolean }> }>(
`/fixtures/search-ofl?query=${encodeURIComponent(query)}`,
{ method: "POST" }
),
previewFixture: (manufacturerKey: string, fixtureKey: string) =>
request<FixturePreview>(
`/fixtures/preview-ofl?manufacturer_key=${encodeURIComponent(manufacturerKey)}&fixture_key=${encodeURIComponent(fixtureKey)}`,
{ method: "POST" }
),
importFixture: (manufacturerKey: string, fixtureKey: string) =>
request<FixtureSummary>(
`/fixtures/import-ofl?manufacturer_key=${encodeURIComponent(manufacturerKey)}&fixture_key=${encodeURIComponent(fixtureKey)}`,
{ method: "POST" }
),
importFixtureFile: (payload: Record<string, unknown>) =>
request<FixtureSummary>("/fixtures/import-file", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
getLiveMixer: () => request<{ items: LiveMixerItem[] }>("/live/mixer"),
setLiveMixerPatchValues: (patchId: number, values: Record<number, number>) =>
request<{ items: LiveMixerItem[] }>(`/live/mixer/${patchId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify({ values })
}),
setLiveMixerHomeAssistantValues: (mappingId: number, values: Record<number, number>) =>
request<{ items: LiveMixerItem[] }>(`/live/mixer/home-assistant/${mappingId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify({ values })
}),
clearLiveMixerPatchValues: (patchId: number) =>
request<{ items: LiveMixerItem[] }>(`/live/mixer/${patchId}`, { method: "DELETE" }),
clearLiveMixerHomeAssistantValues: (mappingId: number) =>
request<{ items: LiveMixerItem[] }>(`/live/mixer/home-assistant/${mappingId}`, { method: "DELETE" }),
listPatches: () => request<{ items: PatchItem[] }>("/patch"),
validatePatch: (payload: Record<string, unknown>) =>
request<PatchValidation>("/patch/validate", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
createPatch: (payload: Record<string, unknown>) =>
request<PatchItem>("/patch", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
updatePatch: (patchId: number, payload: Record<string, unknown>) =>
request<PatchItem>(`/patch/${patchId}`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
deletePatch: (patchId: number) => request<Record<string, unknown>>(`/patch/${patchId}`, { method: "DELETE" }),
listScenes: () => request<{ items: SceneItem[] }>("/scenes"),
createScene: (payload: Record<string, unknown>) =>
request<SceneItem>("/scenes", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
activateScene: (slug: string) =>
request<SceneItem>(`/scenes/${encodeURIComponent(slug)}/activate`, { method: "POST" }),
releaseScene: (slug: string) =>
request<Record<string, unknown>>(`/scenes/${encodeURIComponent(slug)}/release`, { method: "POST" }),
deleteScene: (sceneId: number) =>
request<Record<string, unknown>>(`/scenes/${sceneId}`, { method: "DELETE" }),
listEffects: () => request<{ items: Array<Record<string, unknown>> }>("/effects"),
createEffect: (payload: Record<string, unknown>) =>
request<Record<string, unknown>>("/effects", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
}),
triggerEffect: (slug: string) =>
request<Record<string, unknown>>(`/effects/${encodeURIComponent(slug)}/trigger`, { method: "POST" }),
stopEffect: (slug: string) =>
request<Record<string, unknown>>(`/effects/${encodeURIComponent(slug)}/stop`, { method: "POST" }),
tapBpm: () => request<Record<string, unknown>>("/bpm/tap", { method: "POST" }),
setManualBpm: (bpm: number) =>
request<Record<string, unknown>>(`/bpm/manual?bpm=${bpm}`, { method: "POST" }),
startAudioBpm: (device: string) =>
request<BpmStatus>("/bpm/audio/start", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ device })
}),
stopAudioBpm: () =>
request<Record<string, string>>("/bpm/audio/stop", { method: "POST" }),
listBpmDevices: () => request<{ devices: BpmDevice[] }>("/bpm/devices"),
getBpmConfig: () => request<BpmConfig>("/bpm/config"),
saveBpmConfig: (preferredDevice: string) =>
request<BpmConfig>("/bpm/config", {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify({ preferred_device: preferredDevice })
}),
blackout: () => request<Record<string, unknown>>("/dmx/blackout", { method: "POST" }),
releaseBlackout: () => request<Record<string, unknown>>("/dmx/release-blackout", { method: "POST" }),
listBackups: () => request<{ items: Array<Record<string, unknown>> }>("/backups"),
createBackup: () => request<Record<string, unknown>>("/backups", { method: "POST" }),
restoreBackup: (backupId: string) =>
request<Record<string, unknown>>(`/backups/${encodeURIComponent(backupId)}/restore`, {
method: "POST"
}),
triggerMixitup: (slug: string, payload: Record<string, unknown>) =>
request<Record<string, unknown>>(`/triggers/${encodeURIComponent(slug)}`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify(payload)
})
};
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+40
View File
@@ -0,0 +1,40 @@
type BackupPageProps = {
backups: Array<Record<string, unknown>>;
onCreate: () => Promise<void>;
onRestore: (backupId: string) => Promise<void>;
};
export function BackupPage({ backups, onCreate, onRestore }: BackupPageProps) {
return (
<section className="panel section-stack">
<div>
<h2>Backup og restore</h2>
<p className="muted">
Release 1.0 inkluderer backup-endpoints, Linux-opdateringsflow og dokumenteret pre-restore
backup.
</p>
</div>
<div className="event-item">
<strong>Bemærk</strong>
<div className="muted">
Restore og download skal altid verificeres mod den rigtige Debian-installation. Denne
session påstår ikke direkte fysisk hardwaremåling.
</div>
</div>
<div className="button-row">
<button onClick={onCreate}>Opret backup</button>
</div>
<div className="event-list">
{backups.map((backup) => (
<div key={String(backup.id)} className="event-item">
<strong>{String(backup.label)}</strong>
<div className="muted">{String(backup.created_at ?? "ukendt tidspunkt")}</div>
<div className="button-row">
<button onClick={() => onRestore(String(backup.id))}>Restore</button>
</div>
</div>
))}
</div>
</section>
);
}
+478
View File
@@ -0,0 +1,478 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { BpmDevice, BpmStatus, PatchItem } from "../lib/api";
type BpmPageProps = {
status: BpmStatus | null;
devices: BpmDevice[];
patches: PatchItem[];
onTap: () => Promise<void>;
onSetManual: (bpm: number) => Promise<void>;
onStartAudio: (device: string) => Promise<void>;
onStopAudio: () => Promise<void>;
onCreateEffect: (payload: Record<string, unknown>) => Promise<void>;
onTriggerEffect: (slug: string) => Promise<void>;
onStopEffect: (slug: string) => Promise<void>;
};
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function BpmPage({
status,
devices,
patches,
onTap,
onSetManual,
onStartAudio,
onStopAudio,
onCreateEffect,
onTriggerEffect,
onStopEffect
}: BpmPageProps) {
const [manualBpm, setManualBpm] = useState<number>(status?.bpm ?? 120);
const [selectedDevice, setSelectedDevice] = useState<string>(
status?.selected_device && status.selected_device !== "manual"
? status.selected_device
: (devices[0]?.id ?? "alsa:auto")
);
const [targetMode, setTargetMode] = useState<"fixture" | "group">("fixture");
const [selectedPatchId, setSelectedPatchId] = useState<number>(patches[0]?.id ?? 0);
const [selectedGroupName, setSelectedGroupName] = useState<string>("");
const [selectedAttribute, setSelectedAttribute] = useState<string>("");
const [pulseValue, setPulseValue] = useState<number>(255);
const [pulseDurationMs, setPulseDurationMs] = useState<number>(180);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const [displayLevel, setDisplayLevel] = useState<number>(status?.input_level ?? 0);
const [displayPeak, setDisplayPeak] = useState<number>(status?.peak_level ?? 0);
const levelTargetRef = useRef(status?.input_level ?? 0);
const peakTargetRef = useRef(status?.peak_level ?? 0);
useEffect(() => {
if (status?.selected_device && status.selected_device !== "manual") {
setSelectedDevice(status.selected_device);
}
}, [status?.selected_device]);
useEffect(() => {
if (!patches.length) {
setSelectedPatchId(0);
return;
}
if (!patches.some((patch) => patch.id === selectedPatchId)) {
setSelectedPatchId(patches[0].id);
}
}, [patches, selectedPatchId]);
const groupOptions = useMemo(
() => Array.from(new Set(patches.flatMap((patch) => patch.group_names))).sort((left, right) => left.localeCompare(right)),
[patches]
);
useEffect(() => {
if (!selectedGroupName && groupOptions[0]) {
setSelectedGroupName(groupOptions[0]);
}
if (selectedGroupName && !groupOptions.includes(selectedGroupName)) {
setSelectedGroupName(groupOptions[0] ?? "");
}
}, [groupOptions, selectedGroupName]);
const selectedPatch = useMemo(
() => patches.find((patch) => patch.id === selectedPatchId) ?? patches[0] ?? null,
[patches, selectedPatchId]
);
const targetPatches = useMemo(() => {
if (targetMode === "fixture") {
return selectedPatch ? [selectedPatch] : [];
}
return patches.filter((patch) => patch.group_names.includes(selectedGroupName));
}, [patches, selectedGroupName, selectedPatch, targetMode]);
const attributeOptions = useMemo(
() =>
Array.from(
new Set(
targetPatches.flatMap((patch) =>
patch.channels.map((channel) => channel.display_name)
)
)
),
[targetPatches]
);
useEffect(() => {
if (!attributeOptions.length) {
setSelectedAttribute("");
return;
}
if (!selectedAttribute || !attributeOptions.includes(selectedAttribute)) {
setSelectedAttribute(attributeOptions[0]);
}
}, [attributeOptions, selectedAttribute]);
useEffect(() => {
levelTargetRef.current = status?.input_level ?? 0;
peakTargetRef.current = status?.peak_level ?? 0;
}, [status?.input_level, status?.peak_level]);
useEffect(() => {
let frameId = 0;
const animate = () => {
setDisplayLevel((current) => {
const next = current + (levelTargetRef.current - current) * 0.42;
return Math.abs(next - levelTargetRef.current) < 0.003 ? levelTargetRef.current : next;
});
setDisplayPeak((current) => {
const next = current + (peakTargetRef.current - current) * 0.28;
return Math.abs(next - peakTargetRef.current) < 0.003 ? peakTargetRef.current : next;
});
frameId = window.requestAnimationFrame(animate);
};
frameId = window.requestAnimationFrame(animate);
return () => window.cancelAnimationFrame(frameId);
}, []);
const syncSlug = useMemo(() => {
const targetKey =
targetMode === "fixture"
? selectedPatch?.name ?? "fixture"
: selectedGroupName || "group";
return slugify(`bpm-sync-${targetMode}-${targetKey}-${selectedAttribute || "channel"}`);
}, [selectedAttribute, selectedGroupName, selectedPatch?.name, targetMode]);
const syncTargets = useMemo(() => {
const channels = Object.fromEntries(
targetPatches.flatMap((patch) => {
const channel = patch.channels.find((item) => item.display_name === selectedAttribute);
if (!channel) {
return [];
}
const absoluteChannel = patch.start_address + channel.index - 1;
return [[absoluteChannel, pulseValue]];
})
);
const precedence = Object.fromEntries(
targetPatches.flatMap((patch) => {
const channel = patch.channels.find((item) => item.display_name === selectedAttribute);
if (!channel) {
return [];
}
const absoluteChannel = patch.start_address + channel.index - 1;
return [[absoluteChannel, channel.precedence]];
})
);
return { channels, precedence };
}, [pulseValue, selectedAttribute, targetPatches]);
async function handleSaveAndStartSync(): Promise<void> {
if (!selectedAttribute || !targetPatches.length || Object.keys(syncTargets.channels).length === 0) {
setSyncMessage("Vælg mindst én patched lampe og en gyldig attribut før BPM-sync kan startes.");
return;
}
await onCreateEffect({
name:
targetMode === "fixture"
? `BPM sync · ${selectedPatch?.name ?? "Fixture"} · ${selectedAttribute}`
: `BPM sync · ${selectedGroupName} · ${selectedAttribute}`,
slug: syncSlug,
effect_type: "beat-flash",
priority: 60,
cooldown_ms: 0,
parameters: {
duration_ms: pulseDurationMs,
channels: syncTargets.channels,
precedence: syncTargets.precedence,
source: "bpm"
}
});
await onTriggerEffect(syncSlug);
setSyncMessage(
targetMode === "fixture"
? `BPM-sync kører nu på ${selectedPatch?.name ?? "fixture"} / ${selectedAttribute}.`
: `BPM-sync kører nu på gruppe ${selectedGroupName} / ${selectedAttribute}.`
);
}
async function handleStopSync(): Promise<void> {
await onStopEffect(syncSlug);
setSyncMessage("BPM-sync er stoppet for det valgte target.");
}
return (
<section className="panel section-stack">
<div>
<h2>Lyd og BPM</h2>
<p className="muted">
BPM kan drives af manuel værdi, tap-tempo eller rigtig audio-input via ALSA. Ved tab af
input falder systemet tilbage til manuel BPM.
</p>
</div>
<article className="status-card summary-panel">
<div className="compact-stat-bar dense" aria-label="BPM status">
<div className="compact-stat-pill">
<span className="status-label">BPM</span>
<strong>{status?.bpm ?? 0}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Confidence</span>
<strong>{Math.round((status?.confidence ?? 0) * 100)}%</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Kilde</span>
<strong>{status?.mode ?? "ukendt"}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Input</span>
<strong>{status?.audio_connected ? "Live" : "Standby"}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Level</span>
<strong>{Math.round((status?.input_level ?? 0) * 100)}%</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Aktiv device</span>
<strong>{status?.audio_connected ? status.current_device.replace(/^alsa:/, "") : "Ingen"}</strong>
</div>
</div>
</article>
<div className="workspace-grid">
<article className="status-card section-stack">
<h3>Manual og tap</h3>
<label>
<span className="field-label">Manual BPM</span>
<input
type="number"
min={40}
max={220}
value={manualBpm}
onChange={(event) => setManualBpm(Number(event.target.value))}
/>
</label>
<div className="button-row">
<button type="button" onClick={() => onSetManual(manualBpm)}>
Brug manuel BPM
</button>
<button type="button" onClick={onTap}>
Tap tempo
</button>
</div>
</article>
<article className="status-card section-stack">
<h3>Audio-input</h3>
<div className="vu-card">
<div className="vu-card-header">
<strong>VU-meter</strong>
<span className={`pill ${status?.clipping ? "danger" : status?.audio_connected ? "success" : "info"}`}>
{status?.clipping ? "Clip" : status?.audio_connected ? "Signal" : "Ingen signal"}
</span>
</div>
<div className="vu-meter" aria-label="Inputniveau">
<div
className={`vu-meter-fill ${status?.clipping ? "clip" : ""}`}
style={{ width: `${Math.max(2, Math.round(displayLevel * 100))}%` }}
/>
<div
className="vu-meter-peak"
style={{ left: `${Math.round(displayPeak * 100)}%` }}
/>
</div>
<div className="vu-meter-scale">
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100</span>
</div>
<div className="vu-meter-readout muted">
Niveau {Math.round(displayLevel * 100)}% · Peak {Math.round(displayPeak * 100)}%
</div>
</div>
<label>
<span className="field-label">Device</span>
<select value={selectedDevice} onChange={(event) => setSelectedDevice(event.target.value)}>
{devices.map((device) => (
<option key={device.id} value={device.id}>
{device.name}
{device.recommended ? " · anbefalet" : ""}
</option>
))}
</select>
</label>
<div className="muted">
Valgt som standard: {(status?.selected_device ?? "alsa:auto").replace(/^alsa:/, "")}
{" · "}
Aktivt input: {status?.audio_connected ? status.current_device.replace(/^alsa:/, "") : "ingen"}
</div>
<div className="button-row">
{status?.audio_connected ? (
<>
<button type="button" onClick={onStopAudio}>
Stop audioanalyse
</button>
<button type="button" onClick={() => onStartAudio(selectedDevice)}>
Genstart audioanalyse
</button>
</>
) : (
<>
<button type="button" onClick={() => onStartAudio(selectedDevice)}>
Start audioanalyse
</button>
<button type="button" onClick={onStopAudio} disabled>
Stop audioanalyse
</button>
</>
)}
</div>
{status?.last_error ? (
<div className="event-item wizard">
<strong>Seneste fejl</strong>
<div className="muted">{status.last_error}</div>
</div>
) : (
<div className="muted">
Input åbnes som {status?.format ?? "S16_LE"} · {status?.sample_rate ?? 44100} Hz ·{" "}
{status?.channels ?? 1} kanal.
</div>
)}
</article>
</div>
<div className="workspace-grid">
<article className="status-card section-stack">
<h3>BPM-synk til lamper</h3>
<p className="muted">
Her binder du BPM direkte til patched fixtures eller en hel gruppe. Vælg fx dimmer,
strobe eller farve, og start sync uden at hoppe over i andre sider.
</p>
<div className="form-grid">
<label>
<span className="field-label">Target-type</span>
<select value={targetMode} onChange={(event) => setTargetMode(event.target.value as "fixture" | "group")}>
<option value="fixture">Fixture</option>
<option value="group">Gruppe</option>
</select>
</label>
{targetMode === "fixture" ? (
<label>
<span className="field-label">Fixture</span>
<select value={selectedPatchId} onChange={(event) => setSelectedPatchId(Number(event.target.value))}>
{patches.map((patch) => (
<option key={patch.id} value={patch.id}>
{patch.name} · U{patch.universe} · {patch.start_address}-{patch.end_address}
</option>
))}
</select>
</label>
) : (
<label>
<span className="field-label">Gruppe</span>
<select value={selectedGroupName} onChange={(event) => setSelectedGroupName(event.target.value)}>
{groupOptions.map((group) => (
<option key={group} value={group}>
{group}
</option>
))}
</select>
</label>
)}
<label>
<span className="field-label">Attribut</span>
<select value={selectedAttribute} onChange={(event) => setSelectedAttribute(event.target.value)}>
{attributeOptions.map((attribute) => (
<option key={attribute} value={attribute}>
{attribute}
</option>
))}
</select>
</label>
<label>
<span className="field-label">Peak-værdi</span>
<input
type="number"
min={0}
max={255}
value={pulseValue}
onChange={(event) => setPulseValue(Number(event.target.value))}
/>
</label>
<label>
<span className="field-label">Pulse (ms)</span>
<input
type="number"
min={50}
max={1000}
value={pulseDurationMs}
onChange={(event) => setPulseDurationMs(Number(event.target.value))}
/>
</label>
</div>
<div className="button-row">
<button type="button" onClick={() => void handleSaveAndStartSync()} disabled={!targetPatches.length || !selectedAttribute}>
Start BPM-sync lamper
</button>
<button type="button" onClick={() => void handleStopSync()} disabled={!selectedAttribute}>
Stop BPM-sync
</button>
</div>
{syncMessage ? <div className="muted">{syncMessage}</div> : null}
</article>
<article className="status-card section-stack">
<h3>Aktivt BPM-target</h3>
<div className="event-item">
<strong>{targetMode === "fixture" ? selectedPatch?.name ?? "Ingen fixture" : selectedGroupName || "Ingen gruppe"}</strong>
<div className="muted">
{targetMode === "fixture"
? `1 lampe valgt`
: `${targetPatches.length} lamper i gruppen`}
{" · "}
Attribut {selectedAttribute || "ikke valgt"}
</div>
</div>
<div className="event-list">
{targetPatches.map((patch) => {
const channel = patch.channels.find((item) => item.display_name === selectedAttribute);
return (
<div key={patch.id} className="event-item">
<strong>{patch.name}</strong>
<div className="muted">
{channel
? `${selectedAttribute} · DMX ${patch.start_address + channel.index - 1} · ${channel.precedence.toUpperCase()}`
: `${selectedAttribute || "Attribut"} findes ikke på denne fixture`}
</div>
</div>
);
})}
</div>
</article>
</div>
<article className="status-card section-stack">
<span className="status-label">Tilgængelige devices</span>
<div className="event-list">
{devices.map((device) => (
<div key={device.id} className="event-item">
<strong>{device.name}</strong>
<div className="muted">
{device.id} · backend {device.backend}
{device.is_default ? " · default" : ""}
{device.recommended ? " · anbefalet" : ""}
</div>
</div>
))}
</div>
</article>
</section>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useState } from "react";
import { SystemStatus } from "../lib/api";
import { StatusPill } from "../components/StatusPill";
type DashboardPageProps = {
status: SystemStatus | null;
onBlackout: () => void;
onReleaseBlackout: () => void;
onRestartService: () => Promise<string>;
onRebootHost: () => Promise<string>;
};
export function DashboardPage({
status,
onBlackout,
onReleaseBlackout,
onRestartService,
onRebootHost
}: DashboardPageProps) {
const [systemMessage, setSystemMessage] = useState<string | null>(null);
if (!status) {
return <section className="panel">Indlæser systemstatus</section>;
}
const tone = status.engine.blackout
? "danger"
: status.engine.degraded || !status.engine.connected
? "warning"
: "success";
return (
<>
<section className="hero">
<div className="panel">
<div className="pill-row">
<StatusPill label={`Backend: ${status.engine.backend}`} tone="info" />
<StatusPill
label={status.engine.blackout ? "Blackout aktiv" : "Output klar"}
tone={tone}
/>
<StatusPill label={`BPM ${status.bpm.bpm}`} tone="info" />
<StatusPill label={`${status.telemetry.queue_depth}`} tone="warning" />
</div>
<h2>Operations-dashboard</h2>
<p className="muted">
Softwaren viser beregnet og afleveret frame. Den beviser ikke alene fysisk DMX-signal
ved lampen.
</p>
<div className="button-row">
<button onClick={onBlackout}>Aktivér blackout</button>
<button onClick={onReleaseBlackout}>Frigiv blackout</button>
<button
type="button"
onClick={async () => {
setSystemMessage(await onRestartService());
}}
>
Genstart TuxDMX
</button>
<button
type="button"
onClick={async () => {
if (!window.confirm("Vil du genstarte hele computeren nu?")) {
return;
}
setSystemMessage(await onRebootHost());
}}
>
Genstart computer
</button>
</div>
{systemMessage ? <div className="muted">{systemMessage}</div> : null}
</div>
<div className="panel wizard">
<h3>Førstegangsopsætning</h3>
<div className="section-stack">
<div>
<strong>1. Administrator</strong>
<div className="muted">Opret lokal admin, aktiver session og CSRF.</div>
</div>
<div>
<strong>2. DMX/OLA</strong>
<div className="muted">Vælg universe, port og kør en tidsbegrænset kanaltest.</div>
</div>
<div>
<strong>3. Sikkerhed</strong>
<div className="muted">Master-limit, strobegrænser, safe scene og rate limiting.</div>
</div>
<div>
<strong>4. MixItUp</strong>
<div className="muted">Generér token og kopier færdige web-request data.</div>
</div>
</div>
</div>
</section>
<section className="status-grid">
<article className="status-card">
<span className="status-label">Frame rate</span>
<div className="status-value">{status.engine.fps}</div>
</article>
<article className="status-card">
<span className="status-label">CPU</span>
<div className="status-value">{status.telemetry.cpu_percent}%</div>
</article>
<article className="status-card">
<span className="status-label">RAM</span>
<div className="status-value">{status.telemetry.ram_percent}%</div>
</article>
<article className="status-card">
<span className="status-label">Oppetid</span>
<div className="status-value">{status.telemetry.uptime_seconds}s</div>
</article>
</section>
</>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { PatchItem } from "../lib/api";
type EffectsPageProps = {
effects: Array<Record<string, unknown>>;
patches: PatchItem[];
onCreate: (payload: Record<string, unknown>) => Promise<void>;
onTrigger: (slug: string) => Promise<void>;
onStop: (slug: string) => Promise<void>;
};
function describeEffect(effect: Record<string, unknown>): string {
const parameters = effect.parameters;
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
return "Ingen parametre";
}
const typedParameters = parameters as Record<string, unknown>;
const duration = typedParameters.duration_ms;
const channels = typedParameters.channels;
const channelCount = channels && typeof channels === "object" && !Array.isArray(channels)
? Object.keys(channels).length
: 0;
return `Varighed ${duration ?? 0} ms · ${channelCount} kanal(er)`;
}
export function EffectsPage({ effects, patches, onCreate, onTrigger, onStop }: EffectsPageProps) {
const [name, setName] = useState("Raid flash");
const [slug, setSlug] = useState("raid-flash");
const [patchId, setPatchId] = useState<number>(patches[0]?.id ?? 0);
const [channelIndex, setChannelIndex] = useState<number>(patches[0]?.channels[0]?.index ?? 1);
const [value, setValue] = useState(255);
const [durationMs, setDurationMs] = useState(800);
const selectedPatch = useMemo(
() => patches.find((patch) => patch.id === patchId) ?? null,
[patchId, patches]
);
const selectedChannel = useMemo(
() => selectedPatch?.channels.find((channel) => channel.index === channelIndex) ?? selectedPatch?.channels[0] ?? null,
[channelIndex, selectedPatch]
);
useEffect(() => {
if (patches.length === 0) {
setPatchId(0);
return;
}
if (!patches.some((patch) => patch.id === patchId)) {
setPatchId(patches[0].id);
}
}, [patchId, patches]);
useEffect(() => {
if (selectedPatch === null) {
return;
}
if (!selectedPatch.channels.some((channel) => channel.index === channelIndex)) {
setChannelIndex(selectedPatch.channels[0]?.index ?? 1);
}
}, [channelIndex, selectedPatch]);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (selectedPatch === null || selectedChannel === null) {
return;
}
const absoluteChannel = selectedPatch.start_address + selectedChannel.index - 1;
await onCreate({
name,
slug,
effect_type: "beat-flash",
priority: 60,
cooldown_ms: 10000,
parameters: {
duration_ms: durationMs,
patch_id: selectedPatch.id,
channels: { [absoluteChannel]: value },
precedence: { [absoluteChannel]: selectedChannel.precedence }
}
});
}
return (
<section className="panel section-stack">
<div>
<h2>Effekter og chasers</h2>
<p className="muted">
Beat-flash kan nu følge BPM live. Vælg en patched fixture og en kanal som fx dimmer,
strobe eller farve, gem effekten og trig den for at lade lampen pulse i takt.
</p>
</div>
<div className="workspace-grid">
<form className="status-card section-stack" onSubmit={handleSubmit}>
<h3>Ny effekt</h3>
{patches.length === 0 ? (
<div className="muted">Du skal patche mindst én lampe før du kan bygge en effekt.</div>
) : null}
<div className="form-grid">
<label>
<span className="field-label">Navn</span>
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
<span className="field-label">Slug</span>
<input value={slug} onChange={(event) => setSlug(event.target.value)} />
</label>
<label>
<span className="field-label">Fixture</span>
<select value={patchId} onChange={(event) => setPatchId(Number(event.target.value))}>
{patches.map((patch) => (
<option key={patch.id} value={patch.id}>
{patch.name} · {patch.manufacturer} / {patch.model}
</option>
))}
</select>
</label>
<label>
<span className="field-label">Kanal</span>
<select value={channelIndex} onChange={(event) => setChannelIndex(Number(event.target.value))}>
{(selectedPatch?.channels ?? []).map((channel) => (
<option key={channel.index} value={channel.index}>
{channel.display_name} (DMX {selectedPatch!.start_address + channel.index - 1})
</option>
))}
</select>
</label>
<label>
<span className="field-label">Værdi</span>
<input
type="number"
min={0}
max={255}
value={value}
onChange={(event) => setValue(Number(event.target.value))}
/>
</label>
<label>
<span className="field-label">Varighed (ms)</span>
<input
type="number"
min={50}
max={30000}
value={durationMs}
onChange={(event) => setDurationMs(Number(event.target.value))}
/>
</label>
</div>
<div className="button-row">
<button type="submit" disabled={patches.length === 0}>
Gem effekt
</button>
</div>
</form>
<article className="status-card section-stack">
<h3>Aktuelt target</h3>
{selectedPatch && selectedChannel ? (
<>
<div className="muted">
{selectedPatch.name} · {selectedPatch.mode_key} · {selectedPatch.start_address}-{selectedPatch.end_address}
</div>
<div className="event-item">
<strong>{selectedChannel.display_name}</strong>
<div className="muted">
Kanal {selectedChannel.index} · DMX {selectedPatch.start_address + selectedChannel.index - 1} ·{" "}
{selectedChannel.precedence.toUpperCase()}
</div>
</div>
</>
) : (
<div className="muted">Vælg først en patched fixture.</div>
)}
</article>
</div>
<div className="effect-stack">
{effects.map((effect) => (
<div key={String(effect.slug)} className="effect-row">
<article className="status-card">
<span className="status-label">{String(effect.effect_type)}</span>
<h3>{String(effect.name)}</h3>
<div className="button-row">
<button type="button" onClick={() => onTrigger(String(effect.slug))}>
Trigger effekt
</button>
<button type="button" onClick={() => onStop(String(effect.slug))}>
Stop effekt
</button>
</div>
</article>
<article className="status-card">
<span className="status-label">Indstillinger</span>
<div className="muted">{describeEffect(effect)}</div>
<div className="event-item">
<strong>Slug</strong>
<div className="muted">{String(effect.slug)}</div>
</div>
</article>
</div>
))}
</div>
</section>
);
}
+266
View File
@@ -0,0 +1,266 @@
import { ChangeEvent, FormEvent, useMemo, useState } from "react";
import { FixturePreview, FixtureSummary } from "../lib/api";
type FixturesPageProps = {
fixtures: FixtureSummary[];
results: Array<{ fixture_key: string; cached: boolean }>;
onSearch: (query: string) => Promise<void>;
onPreview: (manufacturerKey: string, fixtureKey: string) => Promise<FixturePreview>;
onImport: (manufacturerKey: string, fixtureKey: string) => Promise<FixtureSummary>;
onImportFile: (payload: Record<string, unknown>) => Promise<FixtureSummary>;
onConfigureFixture: (fixtureId: number) => void;
};
export function FixturesPage({
fixtures,
results,
onSearch,
onPreview,
onImport,
onImportFile,
onConfigureFixture
}: FixturesPageProps) {
const [query, setQuery] = useState("showtec phantom");
const [preview, setPreview] = useState<FixturePreview | null>(null);
const [selectedMode, setSelectedMode] = useState("");
const [filePayload, setFilePayload] = useState("");
const [manufacturerName, setManufacturerName] = useState("");
const [manufacturerKey, setManufacturerKey] = useState("");
const [fixtureKey, setFixtureKey] = useState("");
const [fileStatus, setFileStatus] = useState<string | null>(null);
const [searchStatus, setSearchStatus] = useState<string | null>(null);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
await onSearch(query);
}
async function handleFileSelect(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
return;
}
const text = await file.text();
setFilePayload(text);
setFileStatus(`Fil indlæst: ${file.name}`);
if (!fixtureKey) {
setFixtureKey(file.name.replace(/\.json$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-"));
}
}
async function handleFileImport(event: FormEvent) {
event.preventDefault();
try {
const parsed = JSON.parse(filePayload) as Record<string, unknown>;
const sourceValue = parsed.source;
const source =
sourceValue && typeof sourceValue === "object" && !Array.isArray(sourceValue)
? { ...(sourceValue as Record<string, unknown>) }
: {};
if (manufacturerKey.trim()) {
source.manufacturer_key = manufacturerKey.trim();
}
if (fixtureKey.trim()) {
source.fixture_key = fixtureKey.trim();
}
if (Object.keys(source).length > 0) {
parsed.source = source;
}
if (manufacturerName.trim() && !parsed.manufacturer) {
parsed.manufacturer = manufacturerName.trim();
}
const imported = await onImportFile(parsed);
setPreview(imported);
setSelectedMode(imported.modes[0]?.key ?? "");
setFileStatus(`Fixture importeret: ${imported.manufacturer} / ${imported.model}`);
onConfigureFixture(imported.id);
} catch {
setFileStatus("Kunne ikke importere fixture-filen. Kontroller at JSON-indholdet er gyldigt.");
}
}
const activeMode = useMemo(
() => preview?.modes.find((mode) => mode.key === selectedMode) ?? preview?.modes[0] ?? null,
[preview, selectedMode]
);
return (
<section className="panel section-stack">
<div>
<h2>Fixtures og OFL</h2>
<p className="muted">
Søg i OFL direkte fra UI, behold importen lokalt og behold offline fallback via cache.
</p>
<p className="muted">
Når en fixture er importeret, kan den sendes direkte videre til patch-konfiguration.
</p>
</div>
<form className="form-grid" onSubmit={handleSubmit}>
<label>
<span className="field-label">Søgetekst</span>
<input value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
<div className="button-row">
<button type="submit">Søg i OFL</button>
</div>
</form>
<form className="form-grid" onSubmit={handleFileImport}>
<label>
<span className="field-label">Fixture-fil (.json)</span>
<input type="file" accept=".json,application/json" onChange={handleFileSelect} />
</label>
<label>
<span className="field-label">Producentnavn</span>
<input
value={manufacturerName}
onChange={(event) => setManufacturerName(event.target.value)}
placeholder="Fx Eurolite"
/>
</label>
<label>
<span className="field-label">Producent-key</span>
<input
value={manufacturerKey}
onChange={(event) => setManufacturerKey(event.target.value)}
placeholder="fx eurolite"
/>
</label>
<label>
<span className="field-label">Fixture-key</span>
<input
value={fixtureKey}
onChange={(event) => setFixtureKey(event.target.value)}
placeholder="fx led-bar-3-hcl-bar"
/>
</label>
<label className="full-span">
<span className="field-label">JSON-indhold</span>
<textarea
rows={10}
value={filePayload}
onChange={(event) => setFilePayload(event.target.value)}
placeholder="Indsæt eller indlæs en fixture JSON-fil her"
/>
</label>
<div className="button-row">
<button type="submit" disabled={!filePayload.trim()}>
Importér fixture-fil
</button>
</div>
{fileStatus ? (
<article className="event-item full-span">
<div className="muted">{fileStatus}</div>
</article>
) : null}
</form>
<div className="fixture-grid">
{results.map((result) => {
const [manufacturerKey, fixtureKey] = result.fixture_key.split("/");
return (
<article key={result.fixture_key} className="status-card">
<span className="status-label">{result.cached ? "Lokal cache" : "OFL"}</span>
<h3>{result.fixture_key}</h3>
<div className="button-row">
<button
type="button"
onClick={async () => {
try {
const nextPreview = await onPreview(manufacturerKey, fixtureKey);
setPreview(nextPreview);
setSelectedMode(nextPreview.modes[0]?.key ?? "");
setSearchStatus(null);
} catch {
setSearchStatus("Kunne ikke hente preview fra OFL.");
}
}}
>
Vis preview
</button>
<button
type="button"
onClick={async () => {
try {
const imported = await onImport(manufacturerKey, fixtureKey);
setPreview(imported);
setSelectedMode(imported.modes[0]?.key ?? "");
setSearchStatus(`Fixture importeret: ${imported.manufacturer} / ${imported.model}`);
onConfigureFixture(imported.id);
} catch {
setSearchStatus("Import fra OFL fejlede.");
}
}}
>
Importér
</button>
</div>
</article>
);
})}
</div>
{searchStatus ? (
<article className="event-item">
<div className="muted">{searchStatus}</div>
</article>
) : null}
{preview ? (
<article className="status-card">
<span className="status-label">Fixture preview</span>
<h3>
{preview.manufacturer} / {preview.model}
</h3>
<div className="muted">Schema: {preview.schema_version}</div>
<div className="muted">Kategorier: {preview.categories.join(", ") || "Ingen"}</div>
<label>
<span className="field-label">Modevalg</span>
<select value={selectedMode} onChange={(event) => setSelectedMode(event.target.value)}>
{preview.modes.map((mode) => (
<option key={mode.key} value={mode.key}>
{mode.key} ({mode.channel_count} kanaler)
</option>
))}
</select>
</label>
{activeMode ? (
<div className="event-list">
{activeMode.channels.map((channel) => (
<div key={`${activeMode.key}-${channel.index}`} className="event-item">
<strong>
Kanal {channel.index}: {channel.display_name}
</strong>
<div className="muted">
Key: {channel.key} · Precedence: {channel.precedence.toUpperCase()} ·
Opløsning: {channel.resolution}-bit
</div>
</div>
))}
</div>
) : null}
</article>
) : null}
<div className="card-grid">
{fixtures.map((fixture) => (
<article key={fixture.id} className="status-card">
<span className="status-label">{String(fixture.manufacturer ?? "Ukendt producent")}</span>
<h3>{String(fixture.model ?? "Ukendt model")}</h3>
<div className="muted">
Modes: {Array.isArray(fixture.modes) ? fixture.modes.length : 0} · Kilde:{" "}
{fixture.source.manufacturer_key ?? "lokal"} / {fixture.source.fixture_key ?? fixture.slug}
</div>
<div className="button-row">
<button type="button" onClick={() => onConfigureFixture(fixture.id)}>
Konfigurér i patch
</button>
</div>
</article>
))}
</div>
</section>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useMemo, useState } from "react";
import { LiveMixerItem, SystemStatus } from "../lib/api";
type LiveDeskPageProps = {
status: SystemStatus | null;
mixerItems: LiveMixerItem[];
onBlackout: () => Promise<void>;
onReleaseBlackout: () => Promise<void>;
onSetChannel: (item: LiveMixerItem, channelIndex: number, value: number) => Promise<void>;
onClearFixture: (item: LiveMixerItem) => Promise<void>;
};
export function LiveDeskPage({
status,
mixerItems,
onBlackout,
onReleaseBlackout,
onSetChannel,
onClearFixture
}: LiveDeskPageProps) {
const [localValues, setLocalValues] = useState<Record<string, number>>({});
const [selectedItemId, setSelectedItemId] = useState<string | null>(mixerItems[0]?.id ?? null);
useEffect(() => {
const nextValues: Record<string, number> = {};
for (const item of mixerItems) {
for (const channel of item.channels) {
nextValues[`${item.id}:${channel.index}`] = channel.value;
}
}
setLocalValues(nextValues);
}, [mixerItems]);
useEffect(() => {
if (mixerItems.length === 0) {
setSelectedItemId(null);
return;
}
if (selectedItemId === null || !mixerItems.some((item) => item.id === selectedItemId)) {
setSelectedItemId(mixerItems[0].id);
}
}, [mixerItems, selectedItemId]);
const selectedFixture = useMemo(
() => mixerItems.find((item) => item.id === selectedItemId) ?? null,
[mixerItems, selectedItemId]
);
return (
<section className="panel section-stack">
<div className="live-desk-topbar">
<div className="live-desk-topbar-copy">
<h2>Live Desk</h2>
<p className="muted">
Manuel mixer til patched fixtures med hurtig adgang til lamper, niveauer og blackout.
</p>
</div>
<div className="live-desk-topbar-actions">
<button type="button" onClick={onBlackout}>Blackout</button>
<button type="button" onClick={onReleaseBlackout}>Release blackout</button>
</div>
</div>
<article className="status-card summary-panel">
<div className="compact-stat-bar dense" aria-label="Live Desk status">
<article className="compact-stat-pill">
<span className="status-label">Master</span>
<strong>{status?.engine.master ?? 0}</strong>
</article>
<article className="compact-stat-pill">
<span className="status-label">FPS</span>
<strong>{status?.engine.fps ?? 0}</strong>
</article>
<article className="compact-stat-pill">
<span className="status-label">Output</span>
<strong>{status?.engine.blackout ? "Blackout" : "Live"}</strong>
</article>
<article className="compact-stat-pill">
<span className="status-label">Enheder</span>
<strong>{mixerItems.length}</strong>
</article>
</div>
</article>
{mixerItems.length === 0 ? (
<article className="event-item">
<strong>Ingen live-enheder</strong>
<div className="muted">
til Patch og tilføj dine lamper først, eller opret HA-mappings i Settings. Derefter
vises de her som en samlet live-mixer for både fixtures og virtuelle HA-enheder.
</div>
</article>
) : (
<div className="live-desk-layout">
<aside className="status-card live-fixture-list">
<div className="live-fixture-list-header">
<span className="status-label">Enheder i brug</span>
<strong>{mixerItems.length} items</strong>
</div>
<div className="live-fixture-items">
{mixerItems.map((item) => (
<button
key={item.id}
type="button"
className={`live-fixture-button ${selectedItemId === item.id ? "active" : ""}`}
onClick={() => setSelectedItemId(item.id)}
>
<span className="live-fixture-button-title">{item.name}</span>
<span className="live-fixture-button-meta">
U{item.universe} · {item.start_address}-{item.end_address} · {item.mode_key}
</span>
<span className="live-fixture-button-meta">
{item.source_type === "home_assistant"
? `${item.entity_id ?? "HA entity"} · ${item.status ?? "ukendt"}`
: "Patched fixture"}
</span>
</button>
))}
</div>
</aside>
{selectedFixture ? (
<article className="status-card section-stack">
<div className="fixture-header">
<div>
<span className="status-label">Universe {selectedFixture.universe}</span>
<h3>{selectedFixture.name}</h3>
<div className="muted">
{selectedFixture.manufacturer} / {selectedFixture.model} · {selectedFixture.mode_key} ·{" "}
{selectedFixture.start_address}-{selectedFixture.end_address}
</div>
{selectedFixture.source_type === "home_assistant" ? (
<div className="muted">
{selectedFixture.entity_id ?? "Ukendt entity"} · Senest sendt:{" "}
{selectedFixture.last_sent_summary ?? "ingen"}
</div>
) : null}
</div>
<div className="button-row">
<button type="button" onClick={() => onClearFixture(selectedFixture)}>
{selectedFixture.source_type === "home_assistant" ? "Nulstil HA-enhed" : "Nulstil fixture"}
</button>
</div>
</div>
<div className="mixer-channel-list compact">
{selectedFixture.channels.map((channel) => {
const key = `${selectedFixture.id}:${channel.index}`;
const currentValue = localValues[key] ?? channel.value;
return (
<label key={key} className="channel-slider-row">
<div className="channel-slider-row-label">
<strong>{channel.display_name}</strong>
<span className="muted">DMX {channel.absolute_channel}</span>
</div>
<div className="channel-slider-row-track">
<span className="channel-slider-row-key muted">{channel.key}</span>
<input
type="range"
min={0}
max={255}
value={currentValue}
onChange={(event) => {
const next = Number(event.target.value);
setLocalValues((previous) => ({ ...previous, [key]: next }));
}}
onMouseUp={async () => {
await onSetChannel(selectedFixture, channel.index, currentValue);
}}
onTouchEnd={async () => {
await onSetChannel(selectedFixture, channel.index, currentValue);
}}
/>
<span className="channel-value-chip">{currentValue}</span>
</div>
</label>
);
})}
</div>
</article>
) : null}
</div>
)}
</section>
);
}
+536
View File
@@ -0,0 +1,536 @@
import { useMemo, useState } from "react";
import { MidiBridgeStatus, MidiBridgeToken, MidiLearnState, MidiMapping, SceneItem } from "../lib/api";
type MidiPageProps = {
bridges: MidiBridgeStatus[];
mappings: MidiMapping[];
tokens: MidiBridgeToken[];
learnState: MidiLearnState | null;
scenes: SceneItem[];
effects: Array<Record<string, unknown>>;
onSaveMapping: (payload: Record<string, unknown>, mappingId: number | null) => Promise<string>;
onDeleteMapping: (mappingId: number) => Promise<void>;
onTestMapping: (mappingId: number) => Promise<string>;
onCreateToken: (payload: { label: string; bridge_id: string | null; scopes: string[] }) => Promise<MidiBridgeToken>;
onRevokeToken: (tokenId: number) => Promise<void>;
onStartLearn: (timeoutSeconds: number, allowPassthrough: boolean) => Promise<string>;
onCancelLearn: () => Promise<string>;
};
type MidiAction =
| "activate_scene"
| "deactivate_scene"
| "toggle_scene"
| "flash_scene"
| "blackout_on"
| "blackout_off"
| "blackout_toggle"
| "set_master_dimmer"
| "set_scene_intensity"
| "start_effect"
| "stop_effect";
type MidiMode = "trigger" | "toggle" | "hold" | "flash" | "continuous";
type MidiMessageType = "note_on" | "note_off" | "control_change" | "program_change";
const sceneActions: MidiAction[] = [
"activate_scene",
"deactivate_scene",
"toggle_scene",
"flash_scene",
"set_scene_intensity"
];
const effectActions: MidiAction[] = ["start_effect", "stop_effect"];
function targetTypeForAction(action: MidiAction): "scene" | "effect" | "system" | "global" {
if (sceneActions.includes(action)) {
return "scene";
}
if (effectActions.includes(action)) {
return "effect";
}
if (action === "set_master_dimmer") {
return "global";
}
return "system";
}
function defaultModeForAction(action: MidiAction): MidiMode {
if (action === "set_master_dimmer" || action === "set_scene_intensity") {
return "continuous";
}
if (action === "flash_scene") {
return "hold";
}
return "trigger";
}
export function MidiPage({
bridges,
mappings,
tokens,
learnState,
scenes,
effects,
onSaveMapping,
onDeleteMapping,
onTestMapping,
onCreateToken,
onRevokeToken,
onStartLearn,
onCancelLearn
}: MidiPageProps) {
const [message, setMessage] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [name, setName] = useState("");
const [bridgeId, setBridgeId] = useState("*");
const [deviceName, setDeviceName] = useState("*");
const [messageType, setMessageType] = useState<MidiMessageType>("note_on");
const [channel, setChannel] = useState<number>(0);
const [number, setNumber] = useState<number>(36);
const [action, setAction] = useState<MidiAction>("activate_scene");
const [mode, setMode] = useState<MidiMode>("trigger");
const [targetId, setTargetId] = useState<string>("");
const [minimumValue, setMinimumValue] = useState<number>(0);
const [maximumValue, setMaximumValue] = useState<number>(127);
const [enabled, setEnabled] = useState<boolean>(true);
const [tokenLabel, setTokenLabel] = useState("");
const [tokenBridgeId, setTokenBridgeId] = useState("");
const [createdToken, setCreatedToken] = useState<string | null>(null);
const bridgeChoices = useMemo(
() => ["*", ...bridges.map((bridge) => bridge.bridge_id)],
[bridges]
);
const effectChoices = useMemo(
() =>
effects
.map((effect) => ({
slug: String(effect.slug ?? ""),
name: String(effect.name ?? effect.slug ?? "Effect")
}))
.filter((effect) => effect.slug),
[effects]
);
const currentTargetType = targetTypeForAction(action);
function resetForm() {
setEditingId(null);
setName("");
setBridgeId("*");
setDeviceName("*");
setMessageType("note_on");
setChannel(0);
setNumber(36);
setAction("activate_scene");
setMode("trigger");
setTargetId("");
setMinimumValue(0);
setMaximumValue(127);
setEnabled(true);
}
return (
<section className="panel section-stack">
<div>
<h2>MIDI bridge og mappings</h2>
<p className="muted">
Eksterne USB-MIDI-controllere kan nu kobles via den separate Python-bridge. Mappings kalder den
eksisterende scene-, blackout- og effektmotor i backend og sender aldrig DMX direkte.
</p>
</div>
<article className="status-card summary-panel">
<div className="compact-stat-pill">
<span className="status-label">Bridges</span>
<strong>{bridges.length}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Mappings</span>
<strong>{mappings.length}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Tokens</span>
<strong>{tokens.filter((token) => !token.revoked_at).length}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Learn</span>
<strong>{learnState?.active ? "Armeret" : "Klar"}</strong>
</div>
</article>
<div className="workspace-grid">
<article className="status-card section-stack">
<h3>MIDI Learn</h3>
<div className="muted">
Armér læring, tryk en fysisk knap eller bevæg en fader, og brug derefter den fangede besked til
mappingformularen.
</div>
<div className="button-row">
<button type="button" onClick={async () => setMessage(await onStartLearn(15, false))}>
Start learn
</button>
<button type="button" onClick={async () => setMessage(await onCancelLearn())}>
Annullér learn
</button>
<button
type="button"
disabled={!learnState?.captured_event}
onClick={() => {
const captured = learnState?.captured_event;
if (!captured) {
return;
}
setBridgeId(captured.bridge_id || "*");
setDeviceName(captured.device || "*");
setMessageType((captured.message.type as MidiMessageType) ?? "note_on");
setChannel(captured.message.channel ?? 0);
setNumber(captured.message.number ?? 36);
}}
>
Brug seneste learn
</button>
</div>
<div className="muted">
Status: {learnState?.active ? "venter på næste MIDI-event" : "ikke aktiv"} · Timeout:{" "}
{learnState?.expires_at ? new Date(learnState.expires_at).toLocaleTimeString("da-DK") : "ingen"}
</div>
{learnState?.captured_event ? (
<div className="event-item">
<strong>{learnState.captured_event.device}</strong>
<div className="muted">
{learnState.captured_event.message.type} · kanal {learnState.captured_event.message.channel} · nummer{" "}
{learnState.captured_event.message.number} · værdi {learnState.captured_event.message.value}
</div>
</div>
) : (
<div className="muted">Ingen learn-event fanget endnu.</div>
)}
</article>
<article className="status-card section-stack">
<h3>Bridge-tokens</h3>
<div className="form-grid">
<label>
<span className="field-label">Label</span>
<input value={tokenLabel} onChange={(event) => setTokenLabel(event.target.value)} placeholder="fx APC mini bar" />
</label>
<label>
<span className="field-label">Lås til bridge-id</span>
<input
value={tokenBridgeId}
onChange={(event) => setTokenBridgeId(event.target.value)}
placeholder="fx bar-laptop"
/>
</label>
</div>
<div className="button-row">
<button
type="button"
onClick={async () => {
const created = await onCreateToken({
label: tokenLabel || "MIDI bridge",
bridge_id: tokenBridgeId.trim() || null,
scopes: ["midi:connect", "midi:events", "midi:heartbeat"]
});
setCreatedToken(created.token ?? null);
setTokenLabel("");
setTokenBridgeId("");
setMessage("MIDI-token oprettet.");
}}
>
Opret token
</button>
</div>
{createdToken ? <div className="event-item">Kopiér token nu: <strong>{createdToken}</strong></div> : null}
<div className="event-list">
{tokens.map((token) => (
<div key={token.id} className="event-item">
<strong>{token.label}</strong>
<div className="muted">
Preview: {token.token_preview} · Bridge: {token.bridge_id ?? "alle"} · Scopes: {token.scopes.join(", ")}
</div>
<div className="muted">
Oprettet: {new Date(token.created_at).toLocaleString("da-DK")} · Senest brugt:{" "}
{token.last_used_at ? new Date(token.last_used_at).toLocaleString("da-DK") : "aldrig"} ·
Status: {token.revoked_at ? "tilbagekaldt" : "aktiv"}
</div>
{!token.revoked_at ? (
<div className="button-row">
<button
type="button"
onClick={async () => {
await onRevokeToken(token.id);
setMessage("MIDI-token tilbagekaldt.");
}}
>
Tilbagekald token
</button>
</div>
) : null}
</div>
))}
</div>
</article>
</div>
<div className="workspace-grid">
<article className="status-card section-stack">
<h3>Ny MIDI-mapping</h3>
<div className="form-grid">
<label>
<span className="field-label">Navn</span>
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
<span className="field-label">Bridge-id</span>
<select value={bridgeId} onChange={(event) => setBridgeId(event.target.value)}>
{bridgeChoices.map((choice) => (
<option key={choice} value={choice}>
{choice}
</option>
))}
</select>
</label>
<label>
<span className="field-label">Device match</span>
<input value={deviceName} onChange={(event) => setDeviceName(event.target.value)} placeholder="fx USB*" />
</label>
<label>
<span className="field-label">Message type</span>
<select value={messageType} onChange={(event) => setMessageType(event.target.value as MidiMessageType)}>
<option value="note_on">note_on</option>
<option value="note_off">note_off</option>
<option value="control_change">control_change</option>
<option value="program_change">program_change</option>
</select>
</label>
<label>
<span className="field-label">Kanal</span>
<input type="number" min={0} max={15} value={channel} onChange={(event) => setChannel(Number(event.target.value || 0))} />
</label>
<label>
<span className="field-label">Nummer</span>
<input type="number" min={0} max={127} value={number} onChange={(event) => setNumber(Number(event.target.value || 0))} />
</label>
<label>
<span className="field-label">Action</span>
<select
value={action}
onChange={(event) => {
const nextAction = event.target.value as MidiAction;
setAction(nextAction);
setMode(defaultModeForAction(nextAction));
setTargetId("");
}}
>
<option value="activate_scene">activate_scene</option>
<option value="deactivate_scene">deactivate_scene</option>
<option value="toggle_scene">toggle_scene</option>
<option value="flash_scene">flash_scene</option>
<option value="blackout_on">blackout_on</option>
<option value="blackout_off">blackout_off</option>
<option value="blackout_toggle">blackout_toggle</option>
<option value="set_master_dimmer">set_master_dimmer</option>
<option value="set_scene_intensity">set_scene_intensity</option>
<option value="start_effect">start_effect</option>
<option value="stop_effect">stop_effect</option>
</select>
</label>
<label>
<span className="field-label">Mode</span>
<select value={mode} onChange={(event) => setMode(event.target.value as MidiMode)}>
<option value="trigger">trigger</option>
<option value="toggle">toggle</option>
<option value="hold">hold</option>
<option value="flash">flash</option>
<option value="continuous">continuous</option>
</select>
</label>
{currentTargetType === "scene" ? (
<label className="full-span">
<span className="field-label">Scene</span>
<select value={targetId} onChange={(event) => setTargetId(event.target.value)}>
<option value="">Vælg scene</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.slug}>
{scene.name} ({scene.slug})
</option>
))}
</select>
</label>
) : null}
{currentTargetType === "effect" ? (
<label className="full-span">
<span className="field-label">Effect</span>
<select value={targetId} onChange={(event) => setTargetId(event.target.value)}>
<option value="">Vælg effect</option>
{effectChoices.map((effect) => (
<option key={effect.slug} value={effect.slug}>
{effect.name} ({effect.slug})
</option>
))}
</select>
</label>
) : null}
<label>
<span className="field-label">Minimumsværdi</span>
<input
type="number"
min={0}
max={127}
value={minimumValue}
onChange={(event) => setMinimumValue(Number(event.target.value || 0))}
/>
</label>
<label>
<span className="field-label">Maksimumsværdi</span>
<input
type="number"
min={0}
max={127}
value={maximumValue}
onChange={(event) => setMaximumValue(Number(event.target.value || 127))}
/>
</label>
<label>
<span className="field-label">Aktiv</span>
<select value={enabled ? "yes" : "no"} onChange={(event) => setEnabled(event.target.value === "yes")}>
<option value="yes">Ja</option>
<option value="no">Nej</option>
</select>
</label>
</div>
<div className="button-row">
<button
type="button"
onClick={async () => {
setMessage(
await onSaveMapping(
{
name,
enabled,
bridge_id: bridgeId === "*" ? null : bridgeId,
device_name: deviceName === "*" ? null : deviceName,
message_type: messageType,
channel,
number,
action,
target_type: currentTargetType,
target_id: targetId || null,
mode,
minimum_value: minimumValue,
maximum_value: maximumValue
},
editingId
)
);
resetForm();
}}
>
{editingId === null ? "Gem mapping" : "Opdatér mapping"}
</button>
{editingId !== null ? (
<button type="button" onClick={resetForm}>
Annullér redigering
</button>
) : null}
</div>
</article>
<article className="status-card section-stack">
<h3>Aktive bridges</h3>
<div className="event-list">
{bridges.map((bridge) => (
<div key={bridge.bridge_id} className="event-item">
<strong>{bridge.bridge_id}</strong>
<div className="muted">
{bridge.device_name || "Ukendt device"} · {bridge.ip_address ?? "ukendt IP"} ·
{bridge.online ? " online" : " offline"}
</div>
<div className="muted">
Heartbeat:{" "}
{bridge.last_heartbeat_at ? new Date(bridge.last_heartbeat_at).toLocaleString("da-DK") : "ingen"} ·
Seneste event: {bridge.last_event_at ? new Date(bridge.last_event_at).toLocaleString("da-DK") : "ingen"}
</div>
<div className="muted">
Seneste MIDI:{" "}
{bridge.last_event
? `${bridge.last_event.type} ch ${bridge.last_event.channel} no ${bridge.last_event.number} val ${bridge.last_event.value}`
: "ingen"}
</div>
<div className="muted">Fejl: {bridge.last_error ?? "ingen"}</div>
</div>
))}
{bridges.length === 0 ? <div className="muted">Ingen bridges registreret endnu.</div> : null}
</div>
</article>
</div>
<div className="card-grid">
{mappings.map((mapping) => (
<article key={mapping.id} className="status-card section-stack">
<div className="scene-card-header">
<div>
<span className="status-label">{mapping.message_type} #{mapping.number}</span>
<h3>{mapping.name}</h3>
</div>
<span className={`pill ${mapping.active ? "success" : "info"}`}>
{mapping.active ? "Aktiv" : mapping.enabled ? "Klar" : "Deaktiv"}
</span>
</div>
<div className="muted">
Bridge: {mapping.bridge_id ?? "*"} · Device: {mapping.device_name ?? "*"} · Kanal:{" "}
{mapping.channel ?? "*"} · Mode: {mapping.mode}
</div>
<div className="muted">
Action: {mapping.action} · Target: {mapping.target_id ?? "ingen"} · Range: {mapping.minimum_value}-
{mapping.maximum_value}
</div>
<div className="button-row">
<button
type="button"
onClick={() => {
setEditingId(mapping.id);
setName(mapping.name);
setBridgeId(mapping.bridge_id ?? "*");
setDeviceName(mapping.device_name ?? "*");
setMessageType(mapping.message_type);
setChannel(mapping.channel ?? 0);
setNumber(mapping.number);
setAction(mapping.action);
setMode(mapping.mode);
setTargetId(mapping.target_id ?? "");
setMinimumValue(mapping.minimum_value);
setMaximumValue(mapping.maximum_value);
setEnabled(mapping.enabled);
}}
>
Redigér
</button>
<button type="button" onClick={async () => setMessage(await onTestMapping(mapping.id))}>
Test
</button>
<button
type="button"
onClick={async () => {
await onDeleteMapping(mapping.id);
setMessage("MIDI-mapping slettet.");
if (editingId === mapping.id) {
resetForm();
}
}}
>
Slet
</button>
</div>
</article>
))}
</div>
{message ? <div className="muted">{message}</div> : null}
</section>
);
}
+37
View File
@@ -0,0 +1,37 @@
type MixitupPageProps = {
queueDepth: number;
onTestTrigger: () => Promise<void>;
};
export function MixitupPage({ queueDepth, onTestTrigger }: MixitupPageProps) {
return (
<section className="panel section-stack">
<div>
<h2>MixItUp og Twitch</h2>
<p className="muted">
Triggerendpoint svarer med `202 Accepted` og lægger arbejdet i , MixItUp ikke
blokeres af langvarige lyseffekter.
</p>
</div>
<div className="status-grid">
<article className="status-card">
<span className="status-label">Kødybde</span>
<div className="status-value">{queueDepth}</div>
</article>
<article className="status-card">
<span className="status-label">Eksempel-URL</span>
<div className="metric-note">POST /api/v1/triggers/raid</div>
</article>
</div>
<div className="button-row">
<button onClick={onTestTrigger}>Send testtrigger</button>
</div>
<div className="event-item">
<strong>Headers</strong>
<div className="muted">Authorization: Bearer &lt;token&gt;</div>
<div className="muted">Content-Type: application/json</div>
</div>
</section>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { ChannelGrid } from "../components/ChannelGrid";
type MonitorPageProps = {
values: number[];
sources: string[];
events: Array<{ created_at: string; category: string; level: string; message: string }>;
updatedAt?: string | null;
};
export function MonitorPage({ values, sources, events, updatedAt }: MonitorPageProps) {
const activeCount = values.reduce((count, value, index) => {
const source = sources[index] ?? "idle";
return value > 0 || source !== "idle" ? count + 1 : count;
}, 0);
return (
<section className="panel section-stack">
<div>
<h2>DMX monitor og telemetri</h2>
<p className="muted">
Monitoren viser kun aktive DMX-kanaler og opdateres live fra backend. Fysisk
signalverifikation kræver ekstern måling eller understøttet RX/RDM og er ikke påstået her.
</p>
</div>
<article className="status-card summary-panel">
<div className="compact-stat-bar dense" aria-label="DMX monitor status">
<div className="compact-stat-pill">
<span className="status-label">Aktive kanaler</span>
<strong>{activeCount}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Visning</span>
<strong>Kun aktive</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Live</span>
<strong>{updatedAt ? "Opdaterer" : "Afventer"}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Sidst opdateret</span>
<strong>{updatedAt ? new Date(updatedAt).toLocaleTimeString("da-DK") : "--:--:--"}</strong>
</div>
</div>
</article>
<ChannelGrid values={values} sources={sources} />
<div className="event-list">
{events.map((event, index) => (
<div key={`${event.created_at}-${index}`} className="event-item">
<strong>
{event.category} · {event.level}
</strong>
<div className="muted">{event.message}</div>
</div>
))}
</div>
</section>
);
}
+435
View File
@@ -0,0 +1,435 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { FixtureSummary, PatchItem, PatchValidation } from "../lib/api";
const EMPTY_MODES: FixtureSummary["modes"] = [];
type PatchPageProps = {
fixtures: FixtureSummary[];
patches: PatchItem[];
onValidate: (payload: Record<string, unknown>) => Promise<PatchValidation>;
onSave: (payload: Record<string, unknown>, patchId: number | null) => Promise<void>;
onDelete: (patchId: number) => Promise<void>;
preferredDefinitionId?: number | null;
};
function buildChannelLabel(channel: {
index: number;
display_name: string;
precedence: string;
resolution: number;
}): string {
return `${channel.index}. ${channel.display_name} · ${channel.precedence.toUpperCase()} · ${channel.resolution}-bit`;
}
function parseGroupNames(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.filter((item, index, array) => array.findIndex((entry) => entry.toLowerCase() === item.toLowerCase()) === index);
}
export function PatchPage({
fixtures,
patches,
onValidate,
onSave,
onDelete,
preferredDefinitionId = null
}: PatchPageProps) {
const [editingPatchId, setEditingPatchId] = useState<number | null>(null);
const [definitionId, setDefinitionId] = useState<number>(fixtures[0]?.id ?? 0);
const [modeKey, setModeKey] = useState<string>(fixtures[0]?.modes[0]?.key ?? "");
const [startAddress, setStartAddress] = useState<number>(1);
const [name, setName] = useState<string>("");
const [groupNamesText, setGroupNamesText] = useState<string>("front");
const [positionX, setPositionX] = useState<number>(50);
const [positionY, setPositionY] = useState<number>(50);
const [positionZ, setPositionZ] = useState<number>(0);
const [rotation, setRotation] = useState<number>(0);
const [validation, setValidation] = useState<PatchValidation | null>(null);
const selectedFixture = useMemo(
() => fixtures.find((fixture) => fixture.id === definitionId) ?? null,
[definitionId, fixtures]
);
const availableModes = selectedFixture?.modes ?? EMPTY_MODES;
const selectedMode = availableModes.find((mode) => mode.key === modeKey) ?? availableModes[0] ?? null;
const existingGroups = useMemo(
() => Array.from(new Set(patches.flatMap((patch) => patch.group_names))).sort((left, right) => left.localeCompare(right)),
[patches]
);
useEffect(() => {
if (fixtures.length === 0) {
setDefinitionId(0);
setModeKey("");
setName("");
return;
}
if (!fixtures.some((fixture) => fixture.id === definitionId)) {
const firstFixture = fixtures[0];
setDefinitionId(firstFixture.id);
setModeKey(firstFixture.modes[0]?.key ?? "");
setName(`${firstFixture.manufacturer} ${firstFixture.model}`);
}
}, [definitionId, fixtures]);
useEffect(() => {
if (preferredDefinitionId === null) {
return;
}
const fixture = fixtures.find((item) => item.id === preferredDefinitionId);
if (!fixture) {
return;
}
if (definitionId === fixture.id && modeKey) {
return;
}
setEditingPatchId(null);
setDefinitionId(fixture.id);
setModeKey(fixture.modes[0]?.key ?? "");
setName(`${fixture.manufacturer} ${fixture.model}`);
}, [definitionId, fixtures, modeKey, preferredDefinitionId]);
useEffect(() => {
if (selectedFixture === null) {
setModeKey("");
return;
}
if (!availableModes.some((mode) => mode.key === modeKey)) {
setModeKey(availableModes[0]?.key ?? "");
}
if (!name.trim() || editingPatchId === null) {
setName((current) => current.trim() || `${selectedFixture.manufacturer} ${selectedFixture.model}`);
}
}, [availableModes, editingPatchId, modeKey, name, selectedFixture]);
useEffect(() => {
if (selectedFixture === null || !modeKey) {
setValidation(null);
return;
}
let cancelled = false;
void onValidate({
universe: 1,
definition_id: selectedFixture.id,
mode_key: modeKey,
start_address: startAddress,
exclude_patch_id: editingPatchId,
}).then((result) => {
if (!cancelled) {
setValidation(result);
}
});
return () => {
cancelled = true;
};
}, [definitionId, editingPatchId, modeKey, onValidate, selectedFixture, startAddress]);
const selectedRange = validation
? { start: startAddress, end: validation.end_address }
: selectedMode
? { start: startAddress, end: startAddress + selectedMode.channel_count - 1 }
: null;
const occupiedChannels = useMemo(() => new Set(patches.flatMap((patch) => {
if (editingPatchId !== null && patch.id === editingPatchId) {
return [];
}
return Array.from({ length: patch.channel_count }, (_, index) => patch.start_address + index);
})), [editingPatchId, patches]);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (selectedFixture === null || !modeKey || !validation?.valid) {
return;
}
await onSave(
{
universe: 1,
name,
definition_id: selectedFixture.id,
mode_key: modeKey,
start_address: startAddress,
enabled: true,
group_names: parseGroupNames(groupNamesText),
position: {
x: positionX,
y: positionY,
z: positionZ,
rotation,
}
},
editingPatchId
);
setEditingPatchId(null);
setName(`${selectedFixture.manufacturer} ${selectedFixture.model}`);
setGroupNamesText(existingGroups[0] ?? "front");
setStartAddress(1);
setPositionX(50);
setPositionY(50);
setPositionZ(0);
setRotation(0);
}
function handleEdit(patch: PatchItem) {
setEditingPatchId(patch.id);
setDefinitionId(patch.definition_id);
setModeKey(patch.mode_key);
setStartAddress(patch.start_address);
setName(patch.name);
setGroupNamesText(patch.group_names.join(", "));
setPositionX(patch.position.x);
setPositionY(patch.position.y);
setPositionZ(patch.position.z);
setRotation(patch.position.rotation);
}
function handleCancelEdit() {
setEditingPatchId(null);
if (selectedFixture !== null) {
setName(`${selectedFixture.manufacturer} ${selectedFixture.model}`);
}
}
return (
<section className="panel section-stack">
<div>
<h2>Patch og grupper</h2>
<p className="muted">
Patch fixtures med mode, adresse, grupper og sceneplacering. Fixtures med samme grupper kan styres samlet som linkede enheder i scener.
</p>
</div>
<article className="status-card summary-panel">
<div className="compact-stat-bar dense" aria-label="Patch status">
<div className="compact-stat-pill">
<span className="status-label">Fixtures</span>
<strong>{fixtures.length}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Patchede</span>
<strong>{patches.length}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Grupper</span>
<strong>{existingGroups.length}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Universe</span>
<strong>1</strong>
</div>
</div>
</article>
{fixtures.length === 0 ? (
<article className="event-item">
<strong>Ingen importerede fixtures endnu</strong>
<div className="muted">
Importér først en fixture under <em>Fixtures</em>. Derefter kan du vælge mode, gruppe og fysisk placering her.
</div>
</article>
) : (
<form className="section-stack" onSubmit={handleSubmit}>
<div className="workspace-grid patch-workspace">
<div className="section-stack">
<div className="form-grid">
<label>
<span className="field-label">Fixture</span>
<select value={definitionId} onChange={(event) => setDefinitionId(Number(event.target.value))}>
{fixtures.map((fixture) => (
<option key={fixture.id} value={fixture.id}>
{fixture.manufacturer} / {fixture.model}
</option>
))}
</select>
</label>
<label>
<span className="field-label">Mode</span>
<select value={modeKey} onChange={(event) => setModeKey(event.target.value)}>
{availableModes.map((mode) => (
<option key={mode.key} value={mode.key}>
{mode.key} ({mode.channel_count} kanaler)
</option>
))}
</select>
</label>
<label>
<span className="field-label">Navn</span>
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
<span className="field-label">Startadresse</span>
<input
type="number"
min={1}
max={512}
value={startAddress}
onChange={(event) => setStartAddress(Number(event.target.value))}
/>
</label>
<label className="full-span">
<span className="field-label">Grupper / link</span>
<input
value={groupNamesText}
onChange={(event) => setGroupNamesText(event.target.value)}
placeholder="fx front, wash, synk-a"
/>
</label>
</div>
<article className={`event-item ${validation?.valid ? "" : "wizard"}`}>
<strong>Validering</strong>
<div className="muted">Range: {validation?.range ?? "Vælg fixture og mode"}</div>
<div className="muted">
Slutadresse: {validation?.end_address ?? (selectedRange?.end ?? "-")} · Kanaler:{" "}
{validation?.channel_count ?? (selectedMode?.channel_count ?? "-")}
</div>
{validation?.conflicts.length ? (
<div className="event-list">
{validation.conflicts.map((conflict, index) => (
<div key={`${conflict.type}-${index}`} className="muted">
{conflict.message}
</div>
))}
</div>
) : (
<div className="muted">Ingen overlap fundet.</div>
)}
</article>
</div>
<article className="status-card section-stack">
<span className="status-label">Placering</span>
<div className="placement-preview">
<div className="placement-preview-stage">
<div
className="placement-preview-marker"
style={{
left: `${positionX}%`,
top: `${positionY}%`,
transform: `translate(-50%, -50%) rotate(${rotation}deg)`
}}
>
{name.slice(0, 2).toUpperCase()}
</div>
</div>
<div className="form-grid">
<label>
<span className="field-label">X</span>
<input type="number" min={0} max={100} value={positionX} onChange={(event) => setPositionX(Number(event.target.value))} />
</label>
<label>
<span className="field-label">Y</span>
<input type="number" min={0} max={100} value={positionY} onChange={(event) => setPositionY(Number(event.target.value))} />
</label>
<label>
<span className="field-label">Højde</span>
<input type="number" min={0} max={100} value={positionZ} onChange={(event) => setPositionZ(Number(event.target.value))} />
</label>
<label>
<span className="field-label">Rotation</span>
<input type="number" min={0} max={360} value={rotation} onChange={(event) => setRotation(Number(event.target.value))} />
</label>
</div>
</div>
</article>
</div>
<div className="button-row">
<button type="submit" disabled={!validation?.valid || selectedFixture === null}>
{editingPatchId === null ? "Gem patch" : "Opdatér patch"}
</button>
{editingPatchId !== null ? (
<button type="button" onClick={handleCancelEdit}>
Annullér redigering
</button>
) : null}
</div>
</form>
)}
{existingGroups.length > 0 ? (
<article className="status-card">
<span className="status-label">Eksisterende grupper</span>
<div className="pill-row">
{existingGroups.map((group) => (
<span key={group} className="pill info">{group}</span>
))}
</div>
</article>
) : null}
{selectedMode ? (
<article className="status-card">
<span className="status-label">Modekanaler</span>
<h3>{selectedFixture?.manufacturer} / {selectedFixture?.model}</h3>
<div className="event-list">
{selectedMode.channels.map((channel) => (
<div key={`${selectedMode.key}-${channel.index}`} className="event-item">
{buildChannelLabel(channel)}
</div>
))}
</div>
</article>
) : null}
<article className="status-card">
<span className="status-label">Universe-grid</span>
<div className="channel-grid">
{Array.from({ length: 512 }, (_, index) => {
const address = index + 1;
const inSelectedRange = selectedRange !== null && address >= selectedRange.start && address <= selectedRange.end;
const occupied = occupiedChannels.has(address);
const classNames = ["channel-cell"];
if (occupied) {
classNames.push("occupied");
}
if (inSelectedRange) {
classNames.push("selected");
}
if (occupied && inSelectedRange) {
classNames.push("conflict");
}
return (
<div key={address} className={classNames.join(" ")}>
<strong>{address}</strong>
</div>
);
})}
</div>
</article>
<div className="card-grid">
{patches.map((patch) => (
<article key={patch.id} className="status-card">
<span className="status-label">Universe {patch.universe}</span>
<h3>{patch.name}</h3>
<div className="muted">
{patch.manufacturer} / {patch.model}
</div>
<div className="muted">
{patch.mode_key} · {patch.start_address}-{patch.end_address}
</div>
<div className="muted">
Grupper: {patch.group_names.length > 0 ? patch.group_names.join(", ") : "ingen"}
</div>
<div className="muted">
Placering: x {patch.position.x}% · y {patch.position.y}% · rot {patch.position.rotation}°
</div>
<div className="button-row">
<button type="button" onClick={() => handleEdit(patch)}>
Redigér
</button>
<button type="button" onClick={() => onDelete(patch.id)}>
Slet
</button>
</div>
</article>
))}
</div>
</section>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useEffect, useMemo, useState } from "react";
import { PatchItem } from "../lib/api";
type PlacementPageProps = {
patches: PatchItem[];
onSavePlacement: (
patchId: number,
position: { x: number; y: number; z: number; rotation: number }
) => Promise<void>;
};
export function PlacementPage({ patches, onSavePlacement }: PlacementPageProps) {
const [selectedPatchId, setSelectedPatchId] = useState<number | null>(patches[0]?.id ?? null);
const [draftPosition, setDraftPosition] = useState<{ x: number; y: number; z: number; rotation: number }>({
x: 50,
y: 50,
z: 0,
rotation: 0
});
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
const selectedPatch = useMemo(
() => patches.find((patch) => patch.id === selectedPatchId) ?? patches[0] ?? null,
[patches, selectedPatchId]
);
useEffect(() => {
if (patches.length > 0 && (selectedPatchId === null || !patches.some((patch) => patch.id === selectedPatchId))) {
setSelectedPatchId(patches[0].id);
}
}, [patches, selectedPatchId]);
useEffect(() => {
if (!selectedPatch) {
return;
}
setDraftPosition(selectedPatch.position);
setSaveState("idle");
}, [selectedPatch]);
useEffect(() => {
if (!selectedPatch) {
return;
}
const unchanged =
draftPosition.x === selectedPatch.position.x &&
draftPosition.y === selectedPatch.position.y &&
draftPosition.z === selectedPatch.position.z &&
draftPosition.rotation === selectedPatch.position.rotation;
if (unchanged) {
return;
}
setSaveState("saving");
const timeout = window.setTimeout(async () => {
await onSavePlacement(selectedPatch.id, draftPosition);
setSaveState("saved");
window.setTimeout(() => setSaveState("idle"), 900);
}, 220);
return () => window.clearTimeout(timeout);
}, [draftPosition, onSavePlacement, selectedPatch]);
function updateDraft(key: "x" | "y" | "z" | "rotation", value: number) {
setDraftPosition((current) => ({ ...current, [key]: value }));
}
return (
<section className="panel section-stack">
<div>
<h2>Placerings-view</h2>
<p className="muted">
Se patched fixtures som et fysisk layout. Placering bruges videre i scener og er klar til senere visualisering og position-baserede effekter.
</p>
</div>
{patches.length === 0 ? (
<article className="event-item">
<strong>Ingen patched fixtures</strong>
<div className="muted">Patch mindst én lampe først, kan du placere den her.</div>
</article>
) : (
<div className="workspace-grid placement-workspace">
<article className="status-card section-stack">
<div className="placement-stage-header">
<span className="status-label">Scene-layout</span>
{selectedPatch ? (
<span className={`pill ${saveState === "saving" ? "warning" : "info"}`}>
{saveState === "saving" ? "Gemmer..." : saveState === "saved" ? "Gemt" : "Live preview"}
</span>
) : null}
</div>
<div className="placement-stage">
<div className="placement-stage-grid" />
{patches.map((patch) => {
const position = patch.id === selectedPatch?.id ? draftPosition : patch.position;
return (
<button
key={patch.id}
type="button"
className={`placement-marker ${patch.id === selectedPatch?.id ? "active" : ""}`}
style={{
left: `${position.x}%`,
top: `${position.y}%`,
transform: `translate(-50%, -50%) rotate(${position.rotation}deg)`
}}
onClick={() => setSelectedPatchId(patch.id)}
>
<strong>{patch.name}</strong>
<span>U{patch.universe} · {patch.start_address}-{patch.end_address}</span>
</button>
);
})}
</div>
</article>
<article className="status-card section-stack placement-controls">
<h3>{selectedPatch?.name ?? "Vælg fixture"}</h3>
{selectedPatch ? (
<>
<div className="muted">
{selectedPatch.manufacturer} / {selectedPatch.model} · grupper:{" "}
{selectedPatch.group_names.length > 0 ? selectedPatch.group_names.join(", ") : "ingen"}
</div>
<div className="placement-slider-list">
<label className="placement-slider-row">
<span className="field-label">X-position</span>
<div className="placement-slider-track">
<input
type="range"
min={0}
max={100}
value={draftPosition.x}
onChange={(event) => updateDraft("x", Number(event.target.value))}
/>
<span className="channel-value-chip">{draftPosition.x}</span>
</div>
</label>
<label className="placement-slider-row">
<span className="field-label">Y-position</span>
<div className="placement-slider-track">
<input
type="range"
min={0}
max={100}
value={draftPosition.y}
onChange={(event) => updateDraft("y", Number(event.target.value))}
/>
<span className="channel-value-chip">{draftPosition.y}</span>
</div>
</label>
<label className="placement-slider-row">
<span className="field-label">Højde</span>
<div className="placement-slider-track">
<input
type="range"
min={0}
max={100}
value={draftPosition.z}
onChange={(event) => updateDraft("z", Number(event.target.value))}
/>
<span className="channel-value-chip">{draftPosition.z}</span>
</div>
</label>
<label className="placement-slider-row">
<span className="field-label">Rotation</span>
<div className="placement-slider-track">
<input
type="range"
min={0}
max={360}
value={draftPosition.rotation}
onChange={(event) => updateDraft("rotation", Number(event.target.value))}
/>
<span className="channel-value-chip">{draftPosition.rotation}°</span>
</div>
</label>
</div>
<div className="event-item placement-readout">
<strong>Aktuel placering</strong>
<div className="muted">
X {draftPosition.x} · Y {draftPosition.y} · Højde {draftPosition.z} · Rotation {draftPosition.rotation}°
</div>
</div>
</>
) : null}
</article>
</div>
)}
</section>
);
}
+318
View File
@@ -0,0 +1,318 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { PatchItem, SceneItem } from "../lib/api";
type SceneTargetDraftValue = Record<string, string>;
type ScenesPageProps = {
scenes: SceneItem[];
patches: PatchItem[];
onCreate: (payload: Record<string, unknown>) => Promise<void>;
onActivate: (slug: string) => Promise<void>;
onRelease: (slug: string) => Promise<void>;
onDelete: (sceneId: number) => Promise<void>;
};
function buildSlug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function collectAttributes(patch: PatchItem | undefined): string[] {
return (patch?.channels ?? []).map((channel) => channel.display_name);
}
export function ScenesPage({ scenes, patches, onCreate, onActivate, onRelease, onDelete }: ScenesPageProps) {
const [name, setName] = useState("Stream base");
const [slug, setSlug] = useState("stream-base");
const [priority, setPriority] = useState(10);
const [targetType, setTargetType] = useState<"fixture" | "group">("group");
const [patchId, setPatchId] = useState<number>(patches[0]?.id ?? 0);
const [groupName, setGroupName] = useState<string>("");
const [targetValues, setTargetValues] = useState<SceneTargetDraftValue>({});
const [draftTargets, setDraftTargets] = useState<SceneItem["targets"]>([]);
const availableGroups = useMemo(
() => Array.from(new Set(patches.flatMap((patch) => patch.group_names))).sort((left, right) => left.localeCompare(right)),
[patches]
);
const selectedPatch = useMemo(
() => patches.find((patch) => patch.id === patchId) ?? patches[0],
[patchId, patches]
);
const selectedGroupPatch = useMemo(
() => patches.find((patch) => patch.group_names.includes(groupName)) ?? patches[0],
[groupName, patches]
);
const channelAttributes = useMemo(
() => collectAttributes(targetType === "fixture" ? selectedPatch : selectedGroupPatch),
[selectedGroupPatch, selectedPatch, targetType]
);
useEffect(() => {
if (!patches.some((patch) => patch.id === patchId) && patches[0]) {
setPatchId(patches[0].id);
}
if (!groupName && availableGroups[0]) {
setGroupName(availableGroups[0]);
}
}, [availableGroups, groupName, patchId, patches]);
function resetTargetValues() {
setTargetValues({});
}
function addTarget() {
const values = Object.entries(targetValues)
.filter(([, value]) => value.trim() !== "")
.map(([attribute, value]) => ({
attribute,
value: Number(value),
}));
if (values.length === 0) {
return;
}
if (targetType === "fixture" && selectedPatch) {
setDraftTargets((current) => [
...current,
{ target_type: "fixture", patch_id: selectedPatch.id, values }
]);
}
if (targetType === "group" && groupName) {
setDraftTargets((current) => [
...current,
{ target_type: "group", group_name: groupName, values }
]);
}
resetTargetValues();
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (draftTargets.length === 0) {
return;
}
await onCreate({
name,
slug,
color: "#00e5ff",
priority,
fade_in_ms: 500,
fade_out_ms: 500,
hold_ms: 0,
master_limit: 255,
values: [],
targets: draftTargets,
tags: [],
});
setDraftTargets([]);
setName("Ny scene");
setSlug("ny-scene");
setPriority(10);
}
return (
<section className="panel section-stack">
<div>
<h2>Scenes bygget fixtures og grupper</h2>
<p className="muted">
Opret scener mod patched fixtures eller hele grupper. Samme gruppe kan bruges som synk/link tværs af flere lamper.
</p>
</div>
<div className="workspace-grid">
<form className="status-card section-stack" onSubmit={handleSubmit}>
<h3>Ny scene</h3>
<div className="form-grid">
<label>
<span className="field-label">Navn</span>
<input
value={name}
onChange={(event) => {
const nextName = event.target.value;
setName(nextName);
setSlug((current) => (current === buildSlug(name) ? buildSlug(nextName) : current));
}}
/>
</label>
<label>
<span className="field-label">Slug</span>
<input value={slug} onChange={(event) => setSlug(buildSlug(event.target.value))} />
</label>
<label>
<span className="field-label">Prioritet</span>
<input
type="number"
min={1}
max={100}
value={priority}
onChange={(event) => setPriority(Number(event.target.value))}
/>
</label>
<label>
<span className="field-label">Target-type</span>
<select value={targetType} onChange={(event) => setTargetType(event.target.value as "fixture" | "group")}>
<option value="group">Gruppe</option>
<option value="fixture">Fixture</option>
</select>
</label>
{targetType === "fixture" ? (
<label className="full-span">
<span className="field-label">Fixture</span>
<select value={patchId} onChange={(event) => setPatchId(Number(event.target.value))}>
{patches.map((patch) => (
<option key={patch.id} value={patch.id}>
{patch.name} · {patch.manufacturer} / {patch.model}
</option>
))}
</select>
</label>
) : (
<label className="full-span">
<span className="field-label">Gruppe</span>
<select value={groupName} onChange={(event) => setGroupName(event.target.value)}>
{availableGroups.map((group) => (
<option key={group} value={group}>
{group}
</option>
))}
</select>
</label>
)}
</div>
<article className="event-item section-stack">
<strong>Attributter</strong>
<div className="target-attribute-list">
{channelAttributes.map((attribute) => (
<label key={attribute} className="attribute-row-editor">
<span className="attribute-row-name">{attribute}</span>
<div className="attribute-row-controls">
<input
type="range"
min={0}
max={255}
value={Number(targetValues[attribute] ?? 0)}
onChange={(event) =>
setTargetValues((current) => ({
...current,
[attribute]: event.target.value,
}))
}
/>
<input
type="number"
min={0}
max={255}
className="attribute-row-number"
value={targetValues[attribute] ?? ""}
onChange={(event) =>
setTargetValues((current) => ({
...current,
[attribute]: event.target.value,
}))
}
placeholder="-"
/>
<button
type="button"
className="attribute-row-clear"
onClick={() =>
setTargetValues((current) => ({
...current,
[attribute]: "",
}))
}
>
Nul
</button>
</div>
<span className="muted attribute-row-help">Tomt felt ignoreres i scenen</span>
</label>
))}
</div>
<div className="button-row">
<button type="button" onClick={addTarget} disabled={channelAttributes.length === 0}>
Tilføj target til scene
</button>
</div>
</article>
<div className="button-row">
<button type="submit" disabled={draftTargets.length === 0}>
Gem scene
</button>
</div>
</form>
<article className="status-card section-stack">
<h3>Targets i scenen</h3>
{draftTargets.length === 0 ? (
<div className="muted">Tilføj mindst ét fixture- eller gruppetarget for at gemme scenen.</div>
) : (
<div className="event-list">
{draftTargets.map((target, index) => (
<article key={`${target.target_type}-${index}`} className="event-item">
<strong>
{target.target_type === "fixture"
? patches.find((patch) => patch.id === target.patch_id)?.name ?? "Ukendt fixture"
: `Gruppe ${target.group_name ?? "ukendt"}`}
</strong>
<div className="muted">
{target.values.map((entry) => `${entry.attribute}=${entry.value}`).join(" · ")}
</div>
<div className="button-row">
<button
type="button"
onClick={() => setDraftTargets((current) => current.filter((_, currentIndex) => currentIndex !== index))}
>
Fjern target
</button>
</div>
</article>
))}
</div>
)}
</article>
</div>
<div className="card-grid">
{scenes.map((scene) => (
<article key={scene.id} className="status-card section-stack">
<div className="scene-card-header">
<div>
<span className="status-label">Prioritet {scene.priority}</span>
<h3>{scene.name}</h3>
</div>
<span className={`pill ${scene.is_active ? "success" : "info"}`}>
{scene.is_active ? "Aktiv" : "Klar"}
</span>
</div>
<div className="event-list">
{scene.targets.map((target, index) => (
<div key={`${scene.id}-${target.target_type}-${index}`} className="event-item">
<strong>
{target.target_type === "fixture"
? patches.find((patch) => patch.id === target.patch_id)?.name ?? "Fixture"
: `Gruppe ${target.group_name ?? "ukendt"}`}
</strong>
<div className="muted">
{target.values.map((entry) => `${entry.attribute}=${entry.value}`).join(" · ")}
</div>
</div>
))}
</div>
<div className="button-row">
<button type="button" onClick={() => onActivate(scene.slug)}>Aktivér scene</button>
<button type="button" onClick={() => onRelease(scene.slug)}>Frigiv scene</button>
<button type="button" onClick={() => onDelete(scene.id)}>Slet scene</button>
</div>
</article>
))}
</div>
</section>
);
}
+754
View File
@@ -0,0 +1,754 @@
import { useEffect, useState } from "react";
import {
BpmDevice,
BpmStatus,
DmxDevice,
DmxOutputConfig,
HomeAssistantConfig,
HomeAssistantEntity,
HomeAssistantMapping
} from "../lib/api";
type SettingsPageProps = {
bpmStatus: BpmStatus | null;
bpmDevices: BpmDevice[];
dmxConfig: DmxOutputConfig | null;
dmxDevices: DmxDevice[];
haConfig: HomeAssistantConfig | null;
haEntities: HomeAssistantEntity[];
haMappings: HomeAssistantMapping[];
onSaveDmxConfig: (payload: {
backend: "simulator" | "ola" | "artnet";
universe: number;
output_port: string;
target_host: string;
}) => Promise<string>;
onDiscoverArtNet: () => Promise<string>;
onSaveHomeAssistantConfig: (payload: {
enabled: boolean;
base_url: string;
token: string;
default_universe: number;
}) => Promise<string>;
onTestHomeAssistantConnection: () => Promise<string>;
onRefreshHomeAssistantEntities: () => Promise<string>;
onSaveHomeAssistantMapping: (
payload: {
name: string;
universe: number;
start_address: number;
fixture_type: "dimmer" | "rgb" | "rgbw" | "cct" | "switch" | "scene" | "automation";
entity_id: string;
rate_limit_hz: number;
deadband: number;
fade_ms: number;
invert_channel: boolean;
min_value: number;
max_value: number;
enabled: boolean;
master_dimmer: boolean;
},
mappingId: number | null
) => Promise<string>;
onDeleteHomeAssistantMapping: (mappingId: number) => Promise<void>;
onTestHomeAssistantMapping: (mappingId: number) => Promise<string>;
onSaveBpmDevice: (device: string) => Promise<string>;
onRefreshAudioDevices: () => Promise<void>;
onRestartService: () => Promise<string>;
onRebootHost: () => Promise<string>;
};
type FixtureType = "dimmer" | "rgb" | "rgbw" | "cct" | "switch" | "scene" | "automation";
function defaultChannelSpan(fixtureType: FixtureType, masterDimmer: boolean): number {
if (fixtureType === "rgb") {
return masterDimmer ? 4 : 3;
}
if (fixtureType === "rgbw") {
return masterDimmer ? 5 : 4;
}
if (fixtureType === "cct") {
return masterDimmer ? 3 : 2;
}
return 1;
}
export function SettingsPage({
bpmStatus,
bpmDevices,
dmxConfig,
dmxDevices,
haConfig,
haEntities,
haMappings,
onSaveDmxConfig,
onDiscoverArtNet,
onSaveHomeAssistantConfig,
onTestHomeAssistantConnection,
onRefreshHomeAssistantEntities,
onSaveHomeAssistantMapping,
onDeleteHomeAssistantMapping,
onTestHomeAssistantMapping,
onSaveBpmDevice,
onRefreshAudioDevices,
onRestartService,
onRebootHost
}: SettingsPageProps) {
const [message, setMessage] = useState<string | null>(null);
const [audioMessage, setAudioMessage] = useState<string | null>(null);
const [dmxMessage, setDmxMessage] = useState<string | null>(null);
const [haMessage, setHaMessage] = useState<string | null>(null);
const [selectedDevice, setSelectedDevice] = useState<string>(bpmStatus?.selected_device ?? "alsa:auto");
const [selectedBackend, setSelectedBackend] = useState<"simulator" | "ola" | "artnet">(
dmxConfig?.backend ?? "simulator"
);
const [universe, setUniverse] = useState<number>(dmxConfig?.universe ?? 1);
const [outputPort, setOutputPort] = useState<string>(dmxConfig?.output_port ?? "");
const [targetHost, setTargetHost] = useState<string>(dmxConfig?.target_host ?? "");
const [dmxConfigDirty, setDmxConfigDirty] = useState<boolean>(false);
const [haEnabled, setHaEnabled] = useState<boolean>(haConfig?.enabled ?? false);
const [haBaseUrl, setHaBaseUrl] = useState<string>(haConfig?.base_url ?? "");
const [haToken, setHaToken] = useState<string>("");
const [haUniverse, setHaUniverse] = useState<number>(haConfig?.default_universe ?? 10);
const [haConfigDirty, setHaConfigDirty] = useState<boolean>(false);
const [bpmConfigDirty, setBpmConfigDirty] = useState<boolean>(false);
const [editingMappingId, setEditingMappingId] = useState<number | null>(null);
const [mappingName, setMappingName] = useState<string>("");
const [mappingUniverse, setMappingUniverse] = useState<number>(haConfig?.default_universe ?? 10);
const [mappingStartAddress, setMappingStartAddress] = useState<number>(1);
const [mappingFixtureType, setMappingFixtureType] = useState<FixtureType>("rgb");
const [mappingEntityId, setMappingEntityId] = useState<string>("");
const [mappingRateLimit, setMappingRateLimit] = useState<number>(5);
const [mappingDeadband, setMappingDeadband] = useState<number>(2);
const [mappingFadeMs, setMappingFadeMs] = useState<number>(150);
const [mappingInvert, setMappingInvert] = useState<boolean>(false);
const [mappingMinValue, setMappingMinValue] = useState<number>(0);
const [mappingMaxValue, setMappingMaxValue] = useState<number>(255);
const [mappingEnabled, setMappingEnabled] = useState<boolean>(true);
const [mappingMasterDimmer, setMappingMasterDimmer] = useState<boolean>(true);
useEffect(() => {
if (bpmConfigDirty) {
return;
}
setSelectedDevice(bpmStatus?.selected_device ?? "alsa:auto");
}, [bpmConfigDirty, bpmStatus?.selected_device]);
useEffect(() => {
if (dmxConfigDirty) {
return;
}
setSelectedBackend(dmxConfig?.backend ?? "simulator");
setUniverse(dmxConfig?.universe ?? 1);
setOutputPort(dmxConfig?.output_port ?? "");
setTargetHost(dmxConfig?.target_host ?? "");
}, [dmxConfig, dmxConfigDirty]);
useEffect(() => {
if (haConfigDirty) {
return;
}
setHaEnabled(haConfig?.enabled ?? false);
setHaBaseUrl(haConfig?.base_url ?? "");
setHaUniverse(haConfig?.default_universe ?? 10);
if (editingMappingId === null) {
setMappingUniverse(haConfig?.default_universe ?? 10);
}
}, [editingMappingId, haConfig, haConfigDirty]);
const activeDevice = dmxDevices[0] ?? null;
function resetMappingForm() {
setEditingMappingId(null);
setMappingName("");
setMappingUniverse(haUniverse);
setMappingStartAddress(1);
setMappingFixtureType("rgb");
setMappingEntityId("");
setMappingRateLimit(5);
setMappingDeadband(2);
setMappingFadeMs(150);
setMappingInvert(false);
setMappingMinValue(0);
setMappingMaxValue(255);
setMappingEnabled(true);
setMappingMasterDimmer(true);
}
return (
<section className="panel section-stack">
<div>
<h2>Settings</h2>
<p className="muted">
System, DMX, Home Assistant, BPM og servicekontrol samles her som formularstyrede
driftsindstillinger.
</p>
</div>
<div className="event-item">
<strong>Sikkerhedsretning</strong>
<div className="muted">
Sessions, tokens og strobe/master-grænser skal bevares som administratorspecifikke flows.
</div>
</div>
<div className="event-item section-stack">
<strong>DMX output</strong>
<div className="muted">
OLA/USB-DMX fortsætter som fysisk output. Art-Net kan vælges separat til netværksnoder.
</div>
<div className="form-grid">
<label>
<span className="field-label">Backend</span>
<select
value={selectedBackend}
onChange={(event) => {
setDmxConfigDirty(true);
setSelectedBackend(event.target.value as "simulator" | "ola" | "artnet");
}}
>
<option value="simulator">Simulator</option>
<option value="ola">OLA / USB-DMX</option>
<option value="artnet">Art-Net</option>
</select>
</label>
<label>
<span className="field-label">Universe</span>
<input
type="number"
min={1}
max={63999}
value={universe}
onChange={(event) => {
setDmxConfigDirty(true);
setUniverse(Math.max(1, Number(event.target.value || 1)));
}}
/>
</label>
{selectedBackend === "ola" ? (
<label>
<span className="field-label">OLA output-port</span>
<input
value={outputPort}
onChange={(event) => {
setDmxConfigDirty(true);
setOutputPort(event.target.value);
}}
placeholder="fx Anyma USB Device"
/>
</label>
) : null}
{selectedBackend === "artnet" ? (
<label>
<span className="field-label">Art-Net target host</span>
<input
value={targetHost}
onChange={(event) => {
setDmxConfigDirty(true);
setTargetHost(event.target.value);
}}
placeholder="fx 192.168.2.60"
/>
</label>
) : null}
</div>
<div className="muted">
Aktiv backend: {activeDevice?.backend ?? "ukendt"} · Forbundet: {activeDevice?.connected ? "ja" : "nej"} ·
Output: {activeDevice?.output_port ?? "ikke valgt"} · Universe:{" "}
{activeDevice?.universe ?? dmxConfig?.universe ?? 1}
</div>
<div className="button-row">
<button
type="button"
onClick={async () => {
setDmxMessage(
await onSaveDmxConfig({
backend: selectedBackend,
universe,
output_port: outputPort,
target_host: targetHost
})
);
setDmxConfigDirty(false);
}}
>
Gem DMX-output
</button>
<button
type="button"
onClick={async () => {
setDmxMessage(await onDiscoverArtNet());
}}
>
Scan efter Art-Net
</button>
</div>
{selectedBackend === "artnet" ? (
<div className="event-list">
{(dmxConfig?.artnet_nodes ?? []).length > 0 ? (
(dmxConfig?.artnet_nodes ?? []).map((node) => (
<button
key={node.ip}
type="button"
className="event-item"
onClick={() => {
setDmxConfigDirty(true);
setTargetHost(node.ip);
setOutputPort(node.label);
setDmxMessage(`Valgt Art-Net node ${node.label}.`);
}}
>
<strong>{node.label}</strong>
<div className="muted">
Net {node.net} · Sub {node.sub_switch} · Porte {node.port_count}
</div>
</button>
))
) : (
<div className="muted">Ingen Art-Net svar endnu. Du kan stadig skrive IP manuelt.</div>
)}
</div>
) : null}
{dmxMessage ? <div className="muted">{dmxMessage}</div> : null}
</div>
<div className="event-item section-stack">
<strong>Home Assistant-bro</strong>
<div className="muted">
Brug et separat internt universe, fx 10, til Home Assistant-entiteter. Det holder USB-DMX
OLA adskilt fra HA-lys og automationer.
</div>
<div className="form-grid">
<label>
<span className="field-label">Base URL</span>
<input
value={haBaseUrl}
onChange={(event) => {
setHaConfigDirty(true);
setHaBaseUrl(event.target.value);
}}
placeholder="fx http://homeassistant.local:8123"
/>
</label>
<label>
<span className="field-label">Long-lived token</span>
<input
type="password"
value={haToken}
onChange={(event) => {
setHaConfigDirty(true);
setHaToken(event.target.value);
}}
placeholder={haConfig?.has_token ? `${haConfig.token_mask} gemt. Skriv kun ved udskiftning.` : "Indsæt token"}
/>
</label>
<label>
<span className="field-label">Standard HA-universe</span>
<input
type="number"
min={1}
max={63999}
value={haUniverse}
onChange={(event) => {
setHaConfigDirty(true);
setHaUniverse(Math.max(1, Number(event.target.value || 10)));
}}
/>
</label>
<label>
<span className="field-label">Bro aktiv</span>
<select
value={haEnabled ? "yes" : "no"}
onChange={(event) => {
setHaConfigDirty(true);
setHaEnabled(event.target.value === "yes");
}}
>
<option value="yes">Ja</option>
<option value="no">Nej</option>
</select>
</label>
</div>
<div className="muted">
Token gemt: {haConfig?.has_token ? "ja" : "nej"} · Mappings: {haConfig?.mapping_count ?? haMappings.length}
{" · "}Sendte events: {haConfig?.dispatch_count ?? 0} · Fejl: {haConfig?.error_count ?? 0}
</div>
<div className="muted">
Forbindelse: {haConfig?.reachable ? "kontakt OK" : "ingen kontakt"} · Auth:{" "}
{haConfig?.auth_ok ? "OK" : "ikke verificeret"} · Version: {haConfig?.ha_version ?? "ukendt"} ·
Seneste succes:{" "}
{haConfig?.last_connection_success_at
? new Date(haConfig.last_connection_success_at).toLocaleString("da-DK")
: "ingen"}
</div>
<div className="button-row">
<button
type="button"
onClick={async () => {
setHaMessage(
await onSaveHomeAssistantConfig({
enabled: haEnabled,
base_url: haBaseUrl,
token: haToken,
default_universe: haUniverse
})
);
setHaConfigDirty(false);
setHaToken("");
}}
>
Gem HA-config
</button>
<button
type="button"
onClick={async () => {
setHaMessage(await onTestHomeAssistantConnection());
}}
>
Test forbindelse
</button>
<button
type="button"
onClick={async () => {
setHaMessage(await onRefreshHomeAssistantEntities());
}}
>
Hent HA-enheder
</button>
</div>
<div className="form-grid">
<label>
<span className="field-label">Mapping-navn</span>
<input value={mappingName} onChange={(event) => setMappingName(event.target.value)} />
</label>
<label>
<span className="field-label">Universe</span>
<input
type="number"
min={1}
max={63999}
value={mappingUniverse}
onChange={(event) => setMappingUniverse(Math.max(1, Number(event.target.value || haUniverse)))}
/>
</label>
<label>
<span className="field-label">Startadresse</span>
<input
type="number"
min={1}
max={512}
value={mappingStartAddress}
onChange={(event) => setMappingStartAddress(Math.max(1, Number(event.target.value || 1)))}
/>
</label>
<label>
<span className="field-label">Fixturetype</span>
<select value={mappingFixtureType} onChange={(event) => setMappingFixtureType(event.target.value as FixtureType)}>
<option value="dimmer">Dimmer</option>
<option value="rgb">RGB</option>
<option value="rgbw">RGBW</option>
<option value="cct">CCT</option>
<option value="switch">Switch</option>
<option value="scene">Scene</option>
<option value="automation">Automation</option>
</select>
</label>
<label className="full-span">
<span className="field-label">Home Assistant-entity</span>
<input
list="ha-entity-list"
value={mappingEntityId}
onChange={(event) => setMappingEntityId(event.target.value)}
placeholder="fx light.bar_rgb"
/>
<datalist id="ha-entity-list">
{haEntities.map((entity) => (
<option key={entity.entity_id} value={entity.entity_id}>
{entity.friendly_name || entity.entity_id}
</option>
))}
</datalist>
</label>
<label>
<span className="field-label">Rate limit / sek</span>
<input
type="number"
min={0.1}
max={30}
step={0.1}
value={mappingRateLimit}
onChange={(event) => setMappingRateLimit(Number(event.target.value))}
/>
</label>
<label>
<span className="field-label">Deadband</span>
<input
type="number"
min={0}
max={255}
value={mappingDeadband}
onChange={(event) => setMappingDeadband(Number(event.target.value))}
/>
</label>
<label>
<span className="field-label">Fade / smoothing ms</span>
<input
type="number"
min={0}
max={10000}
value={mappingFadeMs}
onChange={(event) => setMappingFadeMs(Number(event.target.value))}
/>
</label>
<label>
<span className="field-label">Master dimmer</span>
<select
value={mappingMasterDimmer ? "yes" : "no"}
onChange={(event) => setMappingMasterDimmer(event.target.value === "yes")}
>
<option value="yes">Ja</option>
<option value="no">Nej</option>
</select>
</label>
<label>
<span className="field-label">Invertér kanal</span>
<select value={mappingInvert ? "yes" : "no"} onChange={(event) => setMappingInvert(event.target.value === "yes")}>
<option value="no">Nej</option>
<option value="yes">Ja</option>
</select>
</label>
<label>
<span className="field-label">Minimumsværdi</span>
<input
type="number"
min={0}
max={255}
value={mappingMinValue}
onChange={(event) => setMappingMinValue(Number(event.target.value))}
/>
</label>
<label>
<span className="field-label">Maksimumsværdi</span>
<input
type="number"
min={0}
max={255}
value={mappingMaxValue}
onChange={(event) => setMappingMaxValue(Number(event.target.value))}
/>
</label>
<label>
<span className="field-label">Aktiv</span>
<select value={mappingEnabled ? "yes" : "no"} onChange={(event) => setMappingEnabled(event.target.value === "yes")}>
<option value="yes">Ja</option>
<option value="no">Nej</option>
</select>
</label>
</div>
<div className="muted">
Kanalspan: {defaultChannelSpan(mappingFixtureType, mappingMasterDimmer)} · Mappingen kan holdes et
separat HA-universe, fx {haUniverse}.
</div>
<div className="button-row">
<button
type="button"
onClick={async () => {
setHaMessage(
await onSaveHomeAssistantMapping(
{
name: mappingName,
universe: mappingUniverse,
start_address: mappingStartAddress,
fixture_type: mappingFixtureType,
entity_id: mappingEntityId,
rate_limit_hz: mappingRateLimit,
deadband: mappingDeadband,
fade_ms: mappingFadeMs,
invert_channel: mappingInvert,
min_value: mappingMinValue,
max_value: mappingMaxValue,
enabled: mappingEnabled,
master_dimmer: mappingMasterDimmer
},
editingMappingId
)
);
resetMappingForm();
}}
>
{editingMappingId === null ? "Gem HA-mapping" : "Opdatér HA-mapping"}
</button>
{editingMappingId !== null ? (
<button type="button" onClick={resetMappingForm}>
Annullér redigering
</button>
) : null}
</div>
<div className="event-list">
{haMappings.map((mapping) => (
<div key={mapping.id} className="event-item">
<strong>{mapping.name}</strong>
<div className="muted">
U{mapping.universe} · {mapping.start_address}-{mapping.start_address + mapping.channel_span - 1} ·{" "}
{mapping.fixture_type} · {mapping.entity_id}
</div>
<div className="muted">
Rate {mapping.rate_limit_hz}/s · deadband {mapping.deadband} · fade {mapping.fade_ms} ms ·{" "}
{mapping.enabled ? "aktiv" : "deaktiv"}
</div>
<div className="muted">
Status: {mapping.in_flight ? "sender" : mapping.status} · Seneste DMX:{" "}
{mapping.last_dmx_values.length > 0 ? mapping.last_dmx_values.join(", ") : "ingen"} ·
Senest sendt: {mapping.last_sent_summary ?? "ingen"}
</div>
<div className="muted">
Seneste succes: {mapping.last_success_at ? new Date(mapping.last_success_at).toLocaleString("da-DK") : "ingen"} ·
Seneste fejl: {mapping.last_error ?? "ingen"}
</div>
<div className="button-row">
<button
type="button"
onClick={() => {
setEditingMappingId(mapping.id);
setMappingName(mapping.name);
setMappingUniverse(mapping.universe);
setMappingStartAddress(mapping.start_address);
setMappingFixtureType(mapping.fixture_type);
setMappingEntityId(mapping.entity_id);
setMappingRateLimit(mapping.rate_limit_hz);
setMappingDeadband(mapping.deadband);
setMappingFadeMs(mapping.fade_ms);
setMappingInvert(mapping.invert_channel);
setMappingMinValue(mapping.min_value);
setMappingMaxValue(mapping.max_value);
setMappingEnabled(mapping.enabled);
setMappingMasterDimmer(mapping.master_dimmer);
}}
>
Redigér
</button>
<button
type="button"
onClick={async () => {
setHaMessage(await onTestHomeAssistantMapping(mapping.id));
}}
>
Test
</button>
<button
type="button"
onClick={async () => {
await onDeleteHomeAssistantMapping(mapping.id);
setHaMessage("HA-mapping slettet.");
if (editingMappingId === mapping.id) {
resetMappingForm();
}
}}
>
Slet
</button>
</div>
</div>
))}
{haMappings.length === 0 ? (
<div className="muted">
Ingen HA-mappings endnu. Brug fx universe 10 adresse 1-4 til en RGB-lampe med master dimmer.
</div>
) : null}
</div>
{haConfig?.last_error ? <div className="muted">{haConfig.last_error}</div> : null}
{haMessage ? <div className="muted">{haMessage}</div> : null}
</div>
<div className="event-item section-stack">
<strong>BPM og mikrofon</strong>
<div className="muted">
Vælg hvilket ALSA-input der skal bruges som standard til BPM og lydanalyse. Auto prøver først
`plughw:CARD=SB,DEV=0`, derefter `plughw:1,0` og til sidst `default`.
</div>
<label>
<span className="field-label">ALSA-input</span>
<select
value={selectedDevice}
onChange={(event) => {
setBpmConfigDirty(true);
setSelectedDevice(event.target.value);
}}
>
{bpmDevices.map((device) => (
<option key={device.id} value={device.id}>
{device.name}
{device.recommended ? " · anbefalet" : ""}
</option>
))}
</select>
</label>
<div className="muted">
Aktivt input: {bpmStatus?.audio_connected ? bpmStatus.current_device.replace(/^alsa:/, "") : "ingen"} ·
Niveau: {Math.round((bpmStatus?.input_level ?? 0) * 100)}% · Format: {bpmStatus?.format ?? "S16_LE"} /{" "}
{bpmStatus?.sample_rate ?? 44100} Hz / {bpmStatus?.channels ?? 1} kanal
</div>
<div className="button-row">
<button
type="button"
onClick={async () => {
setAudioMessage(await onSaveBpmDevice(selectedDevice));
setBpmConfigDirty(false);
}}
>
Gem audio-input
</button>
<button
type="button"
onClick={async () => {
await onRefreshAudioDevices();
setAudioMessage("Audio-device-listen er opdateret.");
}}
>
Opdater device-liste
</button>
</div>
{bpmStatus?.last_error ? <div className="muted">{bpmStatus.last_error}</div> : <div className="muted">Ingen inputfejl registreret.</div>}
{audioMessage ? <div className="muted">{audioMessage}</div> : null}
</div>
<div className="event-item">
<strong>Systemstyring</strong>
<div className="muted">
Brug disse handlinger til at genstarte TuxDMX-servicen eller hele Linux-maskinen efter
konfigurationsændringer.
</div>
<div className="button-row">
<button
type="button"
onClick={async () => {
setMessage(await onRestartService());
}}
>
Genstart TuxDMX
</button>
<button
type="button"
onClick={async () => {
if (!window.confirm("Vil du genstarte hele computeren nu?")) {
return;
}
setMessage(await onRebootHost());
}}
>
Genstart computer
</button>
</div>
{message ? <div className="muted">{message}</div> : null}
</div>
</section>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { SystemStatus } from "../lib/api";
type TelemetryPageProps = {
status: SystemStatus | null;
};
export function TelemetryPage({ status }: TelemetryPageProps) {
return (
<section className="panel section-stack">
<div>
<h2>Telemetry</h2>
<p className="muted">
Systemtelemetri viser software- og backendniveau. Fysisk DMX-signal ved lampen er ikke
verificeret uden ekstern måling eller understøttet RX/RDM.
</p>
</div>
<article className="status-card summary-panel">
<div className="compact-stat-bar dense" aria-label="Telemetry status">
<div className="compact-stat-pill">
<span className="status-label">CPU</span>
<strong>{status?.telemetry.cpu_percent ?? 0}%</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">RAM</span>
<strong>{status?.telemetry.ram_percent ?? 0}%</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Temperatur</span>
<strong>{status?.telemetry.temperature_c ?? "n/a"}</strong>
</div>
<div className="compact-stat-pill">
<span className="status-label">Reconnects</span>
<strong>{status?.telemetry.reconnect_count ?? 0}</strong>
</div>
</div>
</article>
<div className="event-list">
{(status?.telemetry.events ?? []).map((event, index) => (
<div key={`${event.created_at}-${index}`} className="event-item">
<strong>
{event.category} · {event.level}
</strong>
<div className="muted">{event.message}</div>
</div>
))}
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
import { render, screen } from "@testing-library/react";
import { test, vi } from "vitest";
import App from "../src/App";
vi.stubGlobal(
"fetch",
vi.fn((url: string) => {
let payload: Record<string, unknown> = { items: [] };
if (url.includes("/system/status")) {
payload = {
app: "TuxDMX",
setup_required: false,
engine: {
backend: "simulator",
connected: true,
degraded: false,
last_error: null,
blackout: false,
fps: 30,
master: 255,
frame: Array.from({ length: 512 }, () => 0),
source_map: Array.from({ length: 512 }, () => "idle")
},
telemetry: {
uptime_seconds: 10,
cpu_percent: 20,
ram_percent: 30,
temperature_c: null,
queue_depth: 0,
reconnect_count: 0,
last_error: null,
events: []
},
bpm: {
mode: "manual",
bpm: 120,
confidence: 1,
devices: [],
device_details: [],
current_device: "alsa:auto",
selected_device: "alsa:auto",
recommended_device: "alsa:auto",
audio_connected: false,
last_error: null,
input_level: 0,
peak_level: 0,
clipping: false,
sample_rate: 44100,
channels: 1,
format: "S16_LE"
}
};
} else if (url.includes("/dmx/config")) {
payload = {
backend: "simulator",
universe: 1,
output_port: "simulator",
target_host: "",
artnet_nodes: []
};
} else if (url.includes("/dmx/devices")) {
payload = {
devices: [
{
name: "Simulator universe 1",
backend: "simulator",
connected: true,
output_port: "simulator",
universe: 1
}
]
};
} else if (url.includes("/integrations/home-assistant/config")) {
payload = {
enabled: false,
base_url: "",
default_universe: 10,
has_token: false,
token_mask: "",
mapping_count: 0,
dispatch_count: 0,
error_count: 0,
last_error: null,
last_successful_call_at: null,
last_connection_success_at: null,
last_connection_error: null,
ha_version: null,
auth_ok: false,
reachable: false
};
} else if (url.includes("/integrations/home-assistant/mappings")) {
payload = { items: [] };
} else if (url.includes("/integrations/midi/bridges")) {
payload = { items: [] };
} else if (url.includes("/integrations/midi/mappings")) {
payload = { items: [] };
} else if (url.includes("/integrations/midi/tokens")) {
payload = { items: [] };
} else if (url.includes("/integrations/midi/learn")) {
payload = { active: false, allow_passthrough: false, expires_at: null, captured_event: null };
} else if (url.includes("/bpm/devices")) {
payload = { devices: [] };
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve(payload)
});
})
);
class MockWebSocket {
onmessage: ((event: { data: string }) => void) | null = null;
close() {}
}
vi.stubGlobal("WebSocket", MockWebSocket);
test("viser dashboard shell", async () => {
render(<App />);
expect(await screen.findByText("TuxDMX")).toBeInTheDocument();
expect(await screen.findByText("Operations-dashboard")).toBeInTheDocument();
});
+120
View File
@@ -0,0 +1,120 @@
import type { ComponentProps } from "react";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { expect, test, vi } from "vitest";
import { MidiPage } from "../src/pages/MidiPage";
const noopString = vi.fn(async () => "");
const noopVoid = vi.fn(async () => {});
const noopCreateToken = vi.fn(async () => ({
id: 1,
label: "Test",
bridge_id: null,
scopes: ["midi:events"],
created_at: new Date().toISOString(),
revoked_at: null,
last_used_at: null,
token_preview: "********test",
token: "secret"
}));
function buildProps(overrides?: Partial<ComponentProps<typeof MidiPage>>): ComponentProps<typeof MidiPage> {
return {
bridges: [
{
bridge_id: "bar-laptop",
device_name: "USB MIDI Controller",
ip_address: "192.168.2.44",
protocol_version: 1,
online: true,
last_heartbeat_at: "2026-07-24T20:30:10+02:00",
last_event_at: "2026-07-24T20:30:12+02:00",
last_error: null,
last_event: { type: "note_on", channel: 0, number: 36, value: 127 },
updated_at: "2026-07-24T20:30:12+02:00",
created_at: "2026-07-24T20:20:00+02:00"
}
],
mappings: [
{
id: 1,
name: "Scene A",
enabled: true,
bridge_id: "bar-laptop",
device_name: "USB*",
message_type: "note_on",
channel: 0,
number: 36,
action: "activate_scene",
target_type: "scene",
target_id: "stream-base",
mode: "trigger",
minimum_value: 1,
maximum_value: 127,
created_at: "2026-07-24T20:20:00+02:00",
updated_at: "2026-07-24T20:20:00+02:00",
active: true
}
],
tokens: [
{
id: 1,
label: "Bar laptop",
bridge_id: "bar-laptop",
scopes: ["midi:connect", "midi:events", "midi:heartbeat"],
created_at: "2026-07-24T20:10:00+02:00",
revoked_at: null,
last_used_at: null,
token_preview: "********9f2a"
}
],
learnState: {
active: false,
allow_passthrough: false,
expires_at: null,
captured_event: {
bridge_id: "bar-laptop",
device: "USB MIDI Controller",
timestamp: "2026-07-24T20:28:00+02:00",
message: { type: "control_change", channel: 0, number: 14, value: 64 }
}
},
scenes: [
{
id: 1,
name: "Stream base",
slug: "stream-base",
color: "#00e5ff",
icon: null,
priority: 10,
fade_in_ms: 500,
fade_out_ms: 500,
hold_ms: 0,
master_limit: 255,
values: [],
targets: [],
tags: [],
is_active: false
}
],
effects: [{ name: "Raid flash", slug: "raid-flash", effect_type: "beat-flash" }],
onSaveMapping: noopString,
onDeleteMapping: noopVoid,
onTestMapping: noopString,
onCreateToken: noopCreateToken,
onRevokeToken: noopVoid,
onStartLearn: noopString,
onCancelLearn: noopString,
...overrides
};
}
test("viser learn-state, bridge og mappingoversigt", () => {
render(<MidiPage {...buildProps()} />);
expect(screen.getByText("MIDI bridge og mappings")).toBeInTheDocument();
expect(screen.getByText("USB MIDI Controller")).toBeInTheDocument();
expect(screen.getByText("Scene A")).toBeInTheDocument();
expect(screen.getByText("Bar laptop")).toBeInTheDocument();
expect(screen.getByText(/nummer 14 · værdi 64/i)).toBeInTheDocument();
});
+143
View File
@@ -0,0 +1,143 @@
import type { ComponentProps } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { expect, test, vi } from "vitest";
import { SettingsPage } from "../src/pages/SettingsPage";
const noopString = vi.fn(async () => "");
const noopVoid = vi.fn(async () => {});
function buildProps(overrides?: Partial<ComponentProps<typeof SettingsPage>>): ComponentProps<typeof SettingsPage> {
return {
bpmStatus: {
mode: "manual",
bpm: 120,
confidence: 1,
devices: [],
current_device: "alsa:plughw:1,0",
selected_device: "alsa:auto",
recommended_device: "alsa:plughw:1,0",
audio_connected: true,
last_error: null,
input_level: 0.12,
peak_level: 0.25,
clipping: false,
sample_rate: 44100,
channels: 1,
format: "S16_LE"
},
bpmDevices: [
{ id: "alsa:auto", name: "Auto", backend: "alsa", is_default: true },
{ id: "alsa:plughw:1,0", name: "plughw:1,0", backend: "alsa", is_default: false }
],
dmxConfig: {
backend: "artnet",
universe: 1,
output_port: "WLED Matrix Loft",
target_host: "",
artnet_nodes: []
},
dmxDevices: [],
haConfig: {
enabled: true,
base_url: "",
default_universe: 10,
has_token: true,
token_mask: "********",
mapping_count: 0,
dispatch_count: 0,
error_count: 0,
last_error: null,
last_successful_call_at: null,
last_connection_success_at: null,
last_connection_error: null,
ha_version: null,
auth_ok: false,
reachable: false
},
haEntities: [],
haMappings: [],
onSaveDmxConfig: noopString,
onDiscoverArtNet: noopString,
onSaveHomeAssistantConfig: noopString,
onTestHomeAssistantConnection: noopString,
onRefreshHomeAssistantEntities: noopString,
onSaveHomeAssistantMapping: noopString,
onDeleteHomeAssistantMapping: noopVoid,
onTestHomeAssistantMapping: noopString,
onSaveBpmDevice: noopString,
onRefreshAudioDevices: noopVoid,
onRestartService: noopString,
onRebootHost: noopString,
...overrides
};
}
test("bevarer HA base url mens live refresh opdaterer props", () => {
const view = render(<SettingsPage {...buildProps()} />);
const input = screen.getByPlaceholderText("fx http://homeassistant.local:8123") as HTMLInputElement;
fireEvent.change(input, { target: { value: "http://192.168.2.50:8123" } });
expect(input.value).toBe("http://192.168.2.50:8123");
view.rerender(
<SettingsPage
{...buildProps({
haConfig: {
...buildProps().haConfig,
base_url: ""
}
})}
/>
);
expect(screen.getByPlaceholderText("fx http://homeassistant.local:8123")).toHaveValue(
"http://192.168.2.50:8123"
);
});
test("bevarer Art-Net target host mens live refresh opdaterer props", () => {
const view = render(<SettingsPage {...buildProps()} />);
const input = screen.getByPlaceholderText("fx 192.168.2.60") as HTMLInputElement;
fireEvent.change(input, { target: { value: "192.168.2.77" } });
expect(input.value).toBe("192.168.2.77");
view.rerender(
<SettingsPage
{...buildProps({
dmxConfig: {
backend: "artnet",
universe: 1,
output_port: "WLED Matrix Loft",
target_host: "",
artnet_nodes: []
}
})}
/>
);
expect(screen.getByPlaceholderText("fx 192.168.2.60")).toHaveValue("192.168.2.77");
});
test("bevarer valgt audio-input mens live refresh opdaterer props", () => {
const view = render(<SettingsPage {...buildProps()} />);
const select = screen.getByLabelText("ALSA-input") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "alsa:plughw:1,0" } });
expect(select.value).toBe("alsa:plughw:1,0");
view.rerender(
<SettingsPage
{...buildProps({
bpmStatus: {
...buildProps().bpmStatus!,
selected_device: "alsa:auto"
}
})}
/>
);
expect(screen.getByLabelText("ALSA-input")).toHaveValue("alsa:plughw:1,0");
});
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";