diff --git a/CHANGELOG.md b/CHANGELOG.md index 947b691e6..4e937f70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog # 3.1.9 (2025-11-xx) - Added `name` attribute to Staged Input. +- Real-time active provider connection monitoring (dashboard + websocket) +- Source editor: block selection, batch-mode UI and automatic layout +- Fixed SSL certificate field binding in configuration view +- More robust connection-state and provider-handle management +- Streamlined event notifications and provider-count reporting - Added configurable `reverse_proxy.resource_retry` (UI + server) to tune max attempts, base delay, and exponential backoff multiplier for proxied resources. # 3.1.8 (2025-11-06) diff --git a/frontend/src/app/components/source_editor/editor_model.rs b/frontend/src/app/components/source_editor/editor_model.rs index 8743a7507..3c4b847f6 100644 --- a/frontend/src/app/components/source_editor/editor_model.rs +++ b/frontend/src/app/components/source_editor/editor_model.rs @@ -25,6 +25,18 @@ impl BlockType { pub const OUTPUT_XTREAM: &'static str = "OutputXtream"; pub const OUTPUT_HDHOMERUN: &'static str = "OutputHdHomeRun"; pub const OUTPUT_STRM: &'static str = "OutputStrm"; + + pub fn is_input(&self) -> bool { + matches!(self, Self::InputXtream | Self::InputM3u) + } + + pub fn is_target(&self) -> bool { + matches!(self, Self::Target) + } + + // pub fn is_output(&self) -> bool { + // matches!(self, Self::OutputXtream | Self::OutputM3u | Self::OutputHdHomeRun | Self::OutputStrm) + // } } // Convert from String to BlockType diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index aa14cb83f..bdf45c966 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -4,7 +4,7 @@ use yew::prelude::*; use shared::model::{ConfigInputDto, ConfigTargetDto, HdHomeRunTargetOutputDto, M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto}; use crate::app::components::{can_connect, Block, BlockId, BlockInstance, BlockType, BlockView, Connection, EditMode, InputRow, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar}; use crate::app::{PlaylistContext}; -use crate::app::components::source_editor::layout::cluster_layout; +use crate::app::components::source_editor::layout::layout; const BLOCK_WIDTH: f32 = 100.0; const BLOCK_HEIGHT: f32 = 50.0; @@ -95,6 +95,10 @@ pub fn SourceEditor() -> Html { }; gen_blocks.push(block); input_ids.iter().for_each(|input_id| gen_connections.push(Connection { from: *input_id, to: target_id })); + // Test connections + // gen_connections.push(Connection {from: 1, to: 14}); + // gen_connections.push(Connection {from: 17, to: 2}); + for output in &target_config.output { @@ -119,7 +123,7 @@ pub fn SourceEditor() -> Html { } } - cluster_layout(&mut gen_blocks, &gen_connections); + layout(&mut gen_blocks, &gen_connections); get_next_id.set(current_id); blocks_set.set(gen_blocks); connections_set.set(gen_connections); diff --git a/frontend/src/app/components/source_editor/layout.rs b/frontend/src/app/components/source_editor/layout.rs index 663c39122..ceb0428cc 100644 --- a/frontend/src/app/components/source_editor/layout.rs +++ b/frontend/src/app/components/source_editor/layout.rs @@ -1,190 +1,251 @@ -use std::collections::{HashMap, HashSet}; -use crate::app::components::{BlockId, BlockType, Block, Connection}; +use crate::app::components::{Block, BlockId, Connection}; +use std::collections::HashMap; -const BLOCK_SIZE: f32 = 50.0; -const GAP: f32 = 20.0; -const LAYER_DISTANCE: f32 = BLOCK_SIZE * 3.0; const CANVAS_OFFSET: f32 = 10.0; -const ITERATIONS: usize = 5; -/// Cluster structure -struct Cluster { - targets: Vec, - inputs: Vec, - outputs_per_target: HashMap>, +const Y_GAP: f32 = 25.0; +const X_GAP: f32 = 50.0; + +const BLOCK_HEIGHT: f32 = 50.0; +const BLOCK_WIDTH: f32 = 200.0; + +struct TargetBlock { + id: BlockId, + outputs: Option>, + height: f32, + position: (f32, f32), } -/// Build clusters from connections -fn build_clusters(blocks: &[Block], connections: &[Connection]) -> Vec { - let mut clusters = Vec::new(); - let mut visited_targets = HashSet::new(); - let block_map: HashMap = blocks.iter().map(|b| (b.id, b)).collect(); +impl TargetBlock { + pub fn new(id: BlockId, outputs: Option>) -> Self { + let height = Self::height(outputs.as_ref()); + TargetBlock { id, outputs, height, position: (0.0, 0.0) } + } - for block in blocks { - if block.block_type != BlockType::Target || visited_targets.contains(&block.id) { + fn height(outputs: Option<&Vec>) -> f32 { + if let Some(outs) = outputs { + let len = outs.len() as f32; + ((len * BLOCK_HEIGHT) + ((len - 1.0) * Y_GAP)).max(BLOCK_HEIGHT) + } else { + BLOCK_HEIGHT + } + } + + pub fn set_position(&mut self, x: f32, y: f32, blocks: &mut [Block]) { + self.position = (x, y); + let out_x = x + BLOCK_WIDTH + X_GAP; + let mut out_y = y; + if let Some(outputs) = self.outputs.as_ref() { + for out in outputs { + blocks[*out as usize -1].position = (out_x, out_y); + out_y += BLOCK_HEIGHT + Y_GAP; + } + } + blocks[self.id as usize - 1].position = (x, y + (self.height - BLOCK_HEIGHT) / 2.0); + } +} + +fn build_target_blocks(blocks: &mut [Block], connections: &[Connection]) -> Vec { + let mut out_edges: HashMap> = HashMap::new(); + let mut in_edges: HashMap> = HashMap::new(); + + for c in connections { + out_edges.entry(c.from).or_default().push(c.to); + in_edges.entry(c.to).or_default().push(c.from); + } + + blocks + .iter() + .filter(|b| b.block_type.is_target()) + .map(|b| TargetBlock::new(b.id, out_edges.get(&b.id).cloned())) + .collect() +} + + +/// calcuates Barycenter for a Block, based on connected Blocks in given Order-Array +fn barycenter(id: BlockId, map: &HashMap>, order: &[BlockId]) -> f32 { + if let Some(connected) = map.get(&id) { + let mut sum = 0.0; + let mut count = 0; + for &c in connected { + if let Some(pos) = order.iter().position(|&x| x == c) { + sum += pos as f32; + count += 1; + } + } + if count == 0 { f32::INFINITY } else { sum / count as f32 } + } else { + f32::INFINITY + } +} + +/// Counts crossings +fn count_crossings( + input_order: &[BlockId], + target_order: &[BlockId], + connections: &[Connection], +) -> usize { + let input_index: HashMap = + input_order.iter().enumerate().map(|(i, &id)| (id, i)).collect(); + let target_index: HashMap = + target_order.iter().enumerate().map(|(i, &id)| (id, i)).collect(); + + let mut count = 0; + for (i, c1) in connections.iter().enumerate() { + if !input_index.contains_key(&c1.from) || !target_index.contains_key(&c1.to) { continue; } + for c2 in &connections[i + 1..] { + if !input_index.contains_key(&c2.from) || !target_index.contains_key(&c2.to) { + continue; + } + let i1 = input_index[&c1.from]; + let j1 = target_index[&c1.to]; + let i2 = input_index[&c2.from]; + let j2 = target_index[&c2.to]; - let mut cluster = Cluster { - targets: vec![block.id], - inputs: Vec::new(), - outputs_per_target: HashMap::new(), - }; - visited_targets.insert(block.id); - - // Collect inputs connected to this target - for conn in connections.iter().filter(|c| c.to == block.id) { - if let Some(input) = block_map.get(&conn.from) { - if matches!(input.block_type, BlockType::InputXtream | BlockType::InputM3u) { - cluster.inputs.push(input.id); - } + if (i1 < i2 && j1 > j2) || (i1 > i2 && j1 < j2) { + count += 1; } } - - // Collect outputs for this target - let outputs: Vec = connections.iter() - .filter(|c| c.from == block.id) - .filter_map(|c| block_map.get(&c.to).filter(|b| matches!(b.block_type, - BlockType::OutputXtream | - BlockType::OutputM3u | - BlockType::OutputHdHomeRun | - BlockType::OutputStrm)).map(|b| b.id)) - .collect(); - - cluster.outputs_per_target.insert(block.id, outputs); - clusters.push(cluster); } - - clusters + count } -/// Main hierarchical cluster layout function -pub fn cluster_layout(blocks: &mut [Block], connections: &[Connection]) { - let clusters = build_clusters(blocks, connections); - let mut block_map: HashMap = blocks.iter_mut().map(|b| (b.id, b)).collect(); - let mut y_offset = CANVAS_OFFSET; - let mut placed_inputs = HashSet::new(); - - for cluster in clusters { - // Compute cluster height based on max(column heights) - let num_targets = cluster.targets.len(); - let num_inputs = cluster.inputs.len(); - let max_outputs = cluster.outputs_per_target.values().map(|v| v.len()).max().unwrap_or(0); - let cluster_height = ((num_targets.max(num_inputs).max(max_outputs)) as f32 * BLOCK_SIZE) + - (((num_targets.max(num_inputs).max(max_outputs)) -1) as f32 * GAP); - - // --- Step 1: Place Targets (raw Y, ignore outputs for now) - let target_start_y = y_offset + (cluster_height - (num_targets as f32 * BLOCK_SIZE + (num_targets-1) as f32*GAP))/2.0; - let mut target_y_map = HashMap::new(); - for (i, &target_id) in cluster.targets.iter().enumerate() { - let y = target_start_y + i as f32 * (BLOCK_SIZE + GAP) + BLOCK_SIZE/2.0; - if let Some(t) = block_map.get_mut(&target_id) { - t.position = (LAYER_DISTANCE + CANVAS_OFFSET, y); - target_y_map.insert(target_id, y); - } - } - - // --- Step 2: Place Inputs (centered on connected targets) - let mut input_positions: HashMap = HashMap::new(); - let mut input_to_targets: HashMap> = HashMap::new(); - - for &input_id in &cluster.inputs { - let connected_targets: Vec = connections.iter() - .filter(|c| c.from == input_id && cluster.targets.contains(&c.to)) - .map(|c| c.to) - .collect(); - - input_to_targets.insert(input_id, connected_targets.clone()); - - // Average Y of connected targets - let y = if !connected_targets.is_empty() { - connected_targets.iter().map(|t| target_y_map[t]).sum::() / connected_targets.len() as f32 - } else { - cluster_height / 2.0 + y_offset - }; - input_positions.insert(input_id, y); - } - - // --- Step 3: Spread inputs exclusive to a single target - for &target_id in &cluster.targets { - let exclusive_inputs: Vec = input_to_targets.iter() - .filter(|(_, targets)| targets.len() == 1 && targets[0] == target_id) - .map(|(&id, _)| id) - .collect(); - - let count = exclusive_inputs.len(); - if count > 1 { - let target_y = target_y_map[&target_id]; - let total_height = count as f32 * BLOCK_SIZE + (count-1) as f32 * GAP; - let start_y = target_y - total_height / 2.0 + BLOCK_SIZE / 2.0; - - for (i, input_id) in exclusive_inputs.iter().enumerate() { - input_positions.insert(*input_id, start_y + i as f32 * (BLOCK_SIZE + GAP)); - } - } - } - - // --- Step 4: Resolve overlaps iteratively - for _ in 0..ITERATIONS { - let mut sorted_inputs: Vec<(BlockId, f32)> = input_positions.iter().map(|(id, y)| (*id, *y)).collect(); - sorted_inputs.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); - - for i in 1..sorted_inputs.len() { - let prev_y = sorted_inputs[i-1].1; - let curr_id = sorted_inputs[i].0; - let curr_y = sorted_inputs[i].1; - if curr_y - prev_y < BLOCK_SIZE + GAP { - let new_y = prev_y + BLOCK_SIZE + GAP; - if let Some(y_val) = input_positions.get_mut(&curr_id) { - *y_val = new_y; - } - } - } - } - - // --- Step 5: Commit final input positions - for (&input_id, &y) in input_positions.iter() { - if let Some(b) = block_map.get_mut(&input_id) { - b.position = (CANVAS_OFFSET, y); - placed_inputs.insert(input_id); - } - } - - // --- Step 6: Place Outputs (centered under target) - for (&target_id, outputs) in cluster.outputs_per_target.iter() { - let target_y = target_y_map[&target_id]; - let output_count = outputs.len(); - if output_count == 0 { continue; } - - let outputs_height = output_count as f32 * BLOCK_SIZE + (output_count as f32 -1.0)*GAP; - let output_start_y = target_y - outputs_height / 2.0; - - for (i, &out_id) in outputs.iter().enumerate() { - if let Some(b) = block_map.get_mut(&out_id) { - let y = output_start_y + i as f32 * (BLOCK_SIZE + GAP) + BLOCK_SIZE/2.0; - b.position = (2.0*LAYER_DISTANCE + CANVAS_OFFSET, y); - } - } - } - - // --- Step 7: Update y_offset for next cluster - y_offset += cluster_height + GAP*2.0; - } - - // --- Step 8: Place orphan inputs in a separate column - let orphan_inputs: Vec = block_map.keys() - .filter(|id| { - let b = block_map.get(id).unwrap(); - matches!(b.block_type, BlockType::InputXtream | BlockType::InputM3u) - && !placed_inputs.contains(id) - }) - .cloned() +/// Barycentric Sort +pub fn barycentric_sort( + blocks: &[Block], + connections: &[Connection], + iterations: usize, +) -> (Vec, Vec) { + // Initiale Reihenfolge + let mut input_order: Vec = blocks + .iter() + .filter(|b| b.block_type.is_input()) + .map(|b| b.id) + .collect(); + let mut target_order: Vec = blocks + .iter() + .filter(|b| b.block_type.is_target()) + .map(|b| b.id) .collect(); - for (i, input_id) in orphan_inputs.iter().enumerate() { - if let Some(b) = block_map.get_mut(input_id) { - let y = CANVAS_OFFSET + i as f32 * (BLOCK_SIZE + GAP) + BLOCK_SIZE / 2.0; - b.position = (CANVAS_OFFSET, y); + let mut input_to_targets: HashMap> = HashMap::new(); + let mut target_to_inputs: HashMap> = HashMap::new(); + + for con in connections { + if blocks[con.from as usize - 1].block_type.is_input() + && blocks[con.to as usize - 1].block_type.is_target() + { + input_to_targets.entry(con.from).or_default().push(con.to); + target_to_inputs.entry(con.to).or_default().push(con.from); } } + + // Iterative Barycenter-Sortierung + for _ in 0..iterations { + // sort inputs by middle value of targets + input_order.sort_by(|&a, &b| { + barycenter(a, &input_to_targets, &target_order) + .partial_cmp(&barycenter(b, &input_to_targets, &target_order)) + .unwrap() + }); + + // sort targets by middle value of inputs + target_order.sort_by(|&a, &b| { + barycenter(a, &target_to_inputs, &input_order) + .partial_cmp(&barycenter(b, &target_to_inputs, &input_order)) + .unwrap() + }); + } + + // simple local cross optimisation for inputs + let mut improved = true; + for _ in 0..10 { + if !improved { break; } + improved = false; + for i in 0..input_order.len().saturating_sub(1) { + let mut swapped = input_order.clone(); + swapped.swap(i, i + 1); + if count_crossings(&swapped, &target_order, connections) < count_crossings(&input_order, &target_order, connections) { + input_order.swap(i, i + 1); + improved = true; + } + } + } + + // simple local cross optimisation for targets + improved = true; + for _ in 0..10 { + if !improved { break; } + improved = false; + for i in 0..target_order.len().saturating_sub(1) { + let mut swapped = target_order.clone(); + swapped.swap(i, i + 1); + if count_crossings(&input_order, &swapped, connections) < count_crossings(&input_order, &target_order, connections) { + target_order.swap(i, i + 1); + improved = true; + } + } + } + + (input_order, target_order) } + +pub fn layout(blocks: &mut [Block], connections: &[Connection]) { + + let (input_order, target_order) = barycentric_sort(blocks, connections, 5); + + let mut target_blocks = build_target_blocks(blocks, connections); + target_blocks.sort_by_key(|a| target_order.iter().position(|&id| id == a.id).unwrap()); + + let mut start_y = CANVAS_OFFSET; + let start_x = CANVAS_OFFSET + BLOCK_WIDTH + X_GAP; + for target_block in &mut target_blocks { + target_block.set_position(start_x, start_y, blocks); + start_y += target_block.height + Y_GAP; + } + + let target_map: HashMap = target_blocks + .iter() + .map(|t| (t.id, t)) + .collect(); + + let mut last_input_y = CANVAS_OFFSET; + + for block_id in &input_order { + let connected_targets: Vec<&TargetBlock> = connections + .iter() + .filter(|c| c.from == *block_id) + .filter_map(|c| target_map.get(&c.to)) + .copied() + .collect(); + + let desired_y = if connected_targets.is_empty() { + last_input_y + } else { + // Center Y-Position of target + let min_y = connected_targets + .iter() + .map(|t| t.position.1) + .fold(f32::INFINITY, |a, b| a.min(b)); + + let max_y = connected_targets + .iter() + .map(|t| t.position.1 + t.height) + .fold(f32::NEG_INFINITY, |a, b| a.max(b)); + + (min_y + max_y)/2.0 - BLOCK_HEIGHT/2.0 + }; + + let mut final_y = desired_y; + // prevent overlap + if final_y < last_input_y { + final_y = last_input_y; + } + + let block = &mut blocks[*block_id as usize -1]; + block.position = (CANVAS_OFFSET, final_y); + last_input_y = final_y + BLOCK_HEIGHT + Y_GAP; + } +} \ No newline at end of file