mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-30 04:52:29 +02:00
Merge branch 'feature/source_editor' into feature/resource-retry-config-by-dark_breakpoint
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<BlockId>,
|
||||
inputs: Vec<BlockId>,
|
||||
outputs_per_target: HashMap<BlockId, Vec<BlockId>>,
|
||||
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<Vec<BlockId>>,
|
||||
height: f32,
|
||||
position: (f32, f32),
|
||||
}
|
||||
|
||||
/// Build clusters from connections
|
||||
fn build_clusters(blocks: &[Block], connections: &[Connection]) -> Vec<Cluster> {
|
||||
let mut clusters = Vec::new();
|
||||
let mut visited_targets = HashSet::new();
|
||||
let block_map: HashMap<BlockId, &Block> = blocks.iter().map(|b| (b.id, b)).collect();
|
||||
impl TargetBlock {
|
||||
pub fn new(id: BlockId, outputs: Option<Vec<BlockId>>) -> 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<BlockId>>) -> 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<TargetBlock> {
|
||||
let mut out_edges: HashMap<BlockId, Vec<BlockId>> = HashMap::new();
|
||||
let mut in_edges: HashMap<BlockId, Vec<BlockId>> = 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<BlockId, Vec<BlockId>>, 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<BlockId, usize> =
|
||||
input_order.iter().enumerate().map(|(i, &id)| (id, i)).collect();
|
||||
let target_index: HashMap<BlockId, usize> =
|
||||
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<BlockId> = 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<BlockId, &mut Block> = 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<BlockId, f32> = HashMap::new();
|
||||
let mut input_to_targets: HashMap<BlockId, Vec<BlockId>> = HashMap::new();
|
||||
|
||||
for &input_id in &cluster.inputs {
|
||||
let connected_targets: Vec<BlockId> = 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::<f32>() / 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<BlockId> = 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<BlockId> = 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<BlockId>, Vec<BlockId>) {
|
||||
// Initiale Reihenfolge
|
||||
let mut input_order: Vec<BlockId> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.block_type.is_input())
|
||||
.map(|b| b.id)
|
||||
.collect();
|
||||
let mut target_order: Vec<BlockId> = 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<BlockId, Vec<BlockId>> = HashMap::new();
|
||||
let mut target_to_inputs: HashMap<BlockId, Vec<BlockId>> = 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<BlockId, &TargetBlock> = 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user