Merge pull request #57 from mayanayza/fix/binding-edits

fix: port bindings are now reactive to port changes
This commit is contained in:
Maya
2025-11-01 14:44:51 -04:00
committed by GitHub
20 changed files with 126 additions and 88 deletions
@@ -98,7 +98,7 @@
<div>
<!-- Source host info -->
<div class="mb-6 rounded-lg border border-gray-700 bg-gray-800/50 p-4">
<EntityDisplay item={otherHost} displayComponent={HostDisplay} />
<EntityDisplay context={{}} item={otherHost} displayComponent={HostDisplay} />
</div>
<!-- Target selection -->
@@ -88,12 +88,17 @@
</svelte:fragment>
<svelte:fragment slot="config" let:selectedItem let:onChange>
{#if selectedItem}
{#if selectedItem && selectedItem.type == 'Custom'}
<PortConfigPanel
{formApi}
port={selectedItem}
onChange={(updatedPort) => onChange(updatedPort)}
/>
{:else if selectedItem && selectedItem.type != 'Custom'}
<EntityConfigEmpty
title="Standard Port"
subtitle="This is a standard port, and can't be edited"
/>
{:else}
<EntityConfigEmpty
title="No port selected"
@@ -2,6 +2,7 @@
import { form as createForm } from 'svelte-forms';
import GenericModal from '../layout/GenericModal.svelte';
import type { TextFieldType, FormApi, NumberFieldType } from './types';
import { pushWarning } from '$lib/shared/stores/feedback';
export let title: string = 'Edit';
export let isOpen: boolean = false;
@@ -48,6 +49,7 @@
// Check if current fields are valid
if (!$form.valid) {
pushWarning('Form invalid: ' + $form.errors);
return; // Don't proceed if validation fails
} else {
onSave?.();
@@ -1,4 +1,6 @@
<script lang="ts" generics="T, V">
<!-- T: Item type, V: Dropdown option type, C: type of context passed to item -->
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
<script lang="ts" generics="T, V, C">
import { ArrowUp, ArrowDown, Trash2, Plus, Edit } from 'lucide-svelte';
import RichSelect from './RichSelect.svelte';
import ListSelectItem from './ListSelectItem.svelte';
@@ -21,13 +23,13 @@
// Options (dropdown)
export let options: V[] = [];
export let optionDisplayComponent: EntityDisplayComponent<V>;
export let optionDisplayComponent: EntityDisplayComponent<V, C>;
export let showSearch: boolean = false;
// Items
export let items: T[] = [];
export let itemDisplayComponent: EntityDisplayComponent<T>;
export let getItemContext: ((item: T, index: number) => Record<string, unknown>) | null = null;
export let itemDisplayComponent: EntityDisplayComponent<T, C>;
export let getItemContext: (item: T, index: number) => C = () => new Object() as C;
export let formApi: FormApi;
// Item interaction
@@ -166,7 +168,7 @@
<!-- Use slot if provided, otherwise check for inline editing -->
<slot name="item" {item} {index}>
{#if editingIndex === index && itemDisplayComponent.supportsInlineEdit && itemDisplayComponent.renderInlineEdit}
{@const context = getItemContext ? getItemContext(item, index) : undefined}
{@const context = getItemContext(item, index)}
{@const inlineEditConfig = itemDisplayComponent.renderInlineEdit(
item,
(updates) => {
@@ -178,7 +180,7 @@
)}
<svelte:component this={inlineEditConfig.component} {...inlineEditConfig.props} />
{:else}
{@const context = getItemContext ? getItemContext(item, index) : undefined}
{@const context = getItemContext(item, index)}
<ListSelectItem {item} {context} displayComponent={itemDisplayComponent} />
{/if}
</slot>
@@ -1,11 +1,12 @@
<script lang="ts" generics="T">
<!-- T: Item type, C: type of context passed to item -->
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
<script lang="ts" generics="T, C">
import Tag from '../../data/Tag.svelte';
import type { EntityDisplayComponent } from './types';
export let item: T;
export let displayComponent: EntityDisplayComponent<T>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let context: Record<string, any> | undefined = undefined;
export let displayComponent: EntityDisplayComponent<T, C>;
export let context: C;
$: icon = displayComponent.getIcon?.(item, context);
$: tags = displayComponent.getTags?.(item, context) || [];
@@ -1,4 +1,4 @@
<script lang="ts" generics="T">
<script lang="ts" generics="V, C">
import { ChevronDown } from 'lucide-svelte';
import ListSelectItem from './ListSelectItem.svelte';
import type { EntityDisplayComponent } from './types';
@@ -7,14 +7,15 @@
export let label: string = '';
export let selectedValue: string | null = '';
export let options: T[] = [];
export let options: V[] = [];
export let placeholder: string = 'Select an option...';
export let required: boolean = false;
export let disabled: boolean = false;
export let error: string | null = null;
export let onSelect: (value: string) => void;
export let showSearch: boolean = false;
export let displayComponent: EntityDisplayComponent<T>;
export let displayComponent: EntityDisplayComponent<V, C>;
export let getOptionContext: (option: V, index: number) => C = () => new Object() as C;
let isOpen = false;
let dropdownElement: HTMLDivElement;
@@ -27,12 +28,14 @@
$: selectedItem = options.find((i) => displayComponent.getId(i) === selectedValue);
// Filter options based on search text
$: filteredOptions = options.filter((option) => {
$: filteredOptions = options.filter((option, index) => {
if (!filterText.trim()) return true;
const context = getOptionContext(option, index);
const searchTerm = filterText.toLowerCase();
const label = displayComponent.getLabel(option).toLowerCase();
const description = displayComponent.getDescription?.(option)?.toLowerCase() || '';
const description = displayComponent.getDescription?.(option, context)?.toLowerCase() || '';
return label.includes(searchTerm) || description.includes(searchTerm);
});
@@ -45,10 +48,11 @@
return [{ category: null, options: optionsToGroup }];
}
const groups = new SvelteMap<string | null, T[]>();
const groups = new SvelteMap<string | null, V[]>();
optionsToGroup.forEach((option) => {
const category = displayComponent.getCategory!(option);
optionsToGroup.forEach((option, index) => {
const context = getOptionContext(option, index);
const category = displayComponent.getCategory!(option, context);
if (!groups.has(category)) {
groups.set(category, []);
}
@@ -105,11 +109,21 @@
function handleSelect(value: string) {
try {
const item = options.find((i) => displayComponent.getId(i) === value);
if (item && !displayComponent.getIsDisabled?.(item)) {
isOpen = false;
filterText = '';
onSelect(value);
let index;
const item = options.find((o, i) => {
if (displayComponent.getId(o) === value) {
index = i;
return true;
}
return false;
});
if (item && index) {
const context = getOptionContext(item, index);
if (!displayComponent.getIsDisabled?.(item, context)) {
isOpen = false;
filterText = '';
onSelect(value);
}
}
} catch (e) {
console.warn('Error in handleSelect:', e);
@@ -168,7 +182,8 @@
>
<div class="flex min-w-0 flex-1 items-center gap-3">
{#if selectedItem}
<ListSelectItem item={selectedItem} {displayComponent} />
{@const context = getOptionContext(selectedItem, 0)}
<ListSelectItem {context} item={selectedItem} {displayComponent} />
{:else}
<span class="text-secondary"
>{options.length == 0 ? 'No options available' : placeholder}</span
@@ -232,6 +247,7 @@
<!-- Options in this category -->
{#each group.options as option, optionIndex (displayComponent.getId(option))}
{@const context = getOptionContext(option, optionIndex)}
{@const isLastInGroup = optionIndex === group.options.length - 1}
{@const isLastGroup = groupIndex === groupedOptions.length - 1}
<button
@@ -239,18 +255,18 @@
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
if (!displayComponent.getIsDisabled?.(option)) {
if (!displayComponent.getIsDisabled?.(option, context)) {
handleSelect(displayComponent.getId(option));
}
}}
class="w-full px-3 py-3 text-left transition-colors
{!isLastInGroup || !isLastGroup ? 'border-b border-gray-600' : ''}
{displayComponent.getIsDisabled?.(option)
{displayComponent.getIsDisabled?.(option, context)
? 'cursor-not-allowed opacity-50'
: 'hover:bg-gray-600'}"
disabled={displayComponent.getIsDisabled?.(option)}
disabled={displayComponent.getIsDisabled?.(option, context)}
>
<ListSelectItem item={option} {displayComponent} />
<ListSelectItem {context} item={option} {displayComponent} />
</button>
{/each}
{/if}
@@ -2,7 +2,7 @@
import { entities, serviceDefinitions } from '$lib/shared/stores/metadata';
import { getServiceForBinding, getServiceHost } from '$lib/features/services/store';
export const BindingWithServiceDisplay: EntityDisplayComponent<Binding> = {
export const BindingWithServiceDisplay: EntityDisplayComponent<Binding, object> = {
getId: (binding: Binding) => binding.id,
getLabel: (binding: Binding) => {
const service = get(getServiceForBinding(binding.id));
@@ -82,6 +82,7 @@
import { get } from 'svelte/store';
export let item: Binding;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={BindingWithServiceDisplay} />
<ListSelectItem {context} {item} displayComponent={BindingWithServiceDisplay} />
@@ -1,7 +1,7 @@
<script lang="ts" context="module">
import { entities } from '$lib/shared/stores/metadata';
export const DaemonDisplay: EntityDisplayComponent<Daemon> = {
export const DaemonDisplay: EntityDisplayComponent<Daemon, object> = {
getId: (daemon: Daemon) => daemon.id,
getLabel: (daemon: Daemon) => get(getHostFromId(daemon.host_id))?.name || 'Unknown Daemon',
getDescription: (daemon: Daemon) => get(getHostFromId(daemon.host_id))?.description || '',
@@ -21,6 +21,7 @@
import { get } from 'svelte/store';
export let item: Daemon;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={DaemonDisplay} />
<ListSelectItem {item} {context} displayComponent={DaemonDisplay} />
@@ -1,9 +1,10 @@
<script lang="ts" generics="T">
<script lang="ts" generics="T, C">
import ListSelectItem from '$lib/shared/components/forms/selection/ListSelectItem.svelte';
import type { EntityDisplayComponent } from '../types';
export let item: T;
export let displayComponent: EntityDisplayComponent<T>;
export let context: C;
export let displayComponent: EntityDisplayComponent<T, C>;
</script>
<ListSelectItem {item} {displayComponent} />
<ListSelectItem {item} {context} {displayComponent} />
@@ -2,7 +2,7 @@
import type { Host } from '$lib/features/hosts/types/base';
import { entities, serviceDefinitions } from '$lib/shared/stores/metadata';
export const HostDisplay: EntityDisplayComponent<Host> = {
export const HostDisplay: EntityDisplayComponent<Host, object> = {
getId: (host: Host) => host.id,
getLabel: (host: Host) => host.name,
getDescription: (host: Host) => get(getHostTargetString(host)) || 'Unknown Host',
@@ -35,6 +35,7 @@
import { get } from 'svelte/store';
export let item: Host;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={HostDisplay} />
<ListSelectItem {item} {context} displayComponent={HostDisplay} />
@@ -2,10 +2,15 @@
import { entities, serviceDefinitions } from '$lib/shared/stores/metadata';
import { getServiceForBinding } from '$lib/features/services/store';
export const InterfaceBindingDisplay: EntityDisplayComponent<InterfaceBinding> = {
interface ServiceAndHost {
service: Service;
host: Host;
}
export const InterfaceBindingDisplay: EntityDisplayComponent<InterfaceBinding, ServiceAndHost> = {
getId: (binding: InterfaceBinding) => binding.id,
getLabel: (binding: InterfaceBinding) => {
const iface = get(getInterfaceFromId(binding.interface_id));
getLabel: (binding: InterfaceBinding, context) => {
const iface = context?.host.interfaces.find((i) => i.id == binding.interface_id);
const interfaceFormatted = iface ? formatInterface(iface) : 'Unknown Interface';
return interfaceFormatted;
},
@@ -26,7 +31,7 @@
binding: InterfaceBinding,
onUpdate: (updates: Partial<InterfaceBinding>) => void,
formApi: FormApi,
context: { service?: Service; host?: Host }
context: ServiceAndHost
) => {
return {
component: InterfaceBindingInlineEditor,
@@ -45,7 +50,7 @@
<script lang="ts">
import type { EntityDisplayComponent } from '../types';
import ListSelectItem from '../ListSelectItem.svelte';
import { formatInterface, getInterfaceFromId } from '$lib/features/hosts/store';
import { formatInterface } from '$lib/features/hosts/store';
import type { InterfaceBinding, Service } from '$lib/features/services/types/base';
import { Link2 } from 'lucide-svelte';
import type { Host } from '$lib/features/hosts/types/base';
@@ -54,6 +59,7 @@
import type { FormApi } from '../../types';
export let item: InterfaceBinding;
export let context: ServiceAndHost;
</script>
<ListSelectItem {item} displayComponent={InterfaceBindingDisplay} />
<ListSelectItem {item} {context} displayComponent={InterfaceBindingDisplay} />
@@ -2,7 +2,7 @@
import { getSubnetFromId, isContainerSubnet } from '$lib/features/subnets/store';
import { get } from 'svelte/store';
export const InterfaceDisplay: EntityDisplayComponent<Interface> = {
export const InterfaceDisplay: EntityDisplayComponent<Interface, object> = {
getId: (iface: Interface) => iface.id,
getLabel: (iface: Interface) => (iface.name ? iface.name : 'Unnamed Interface'),
getDescription: (iface: Interface) => {
@@ -39,6 +39,7 @@
import { entities } from '$lib/shared/stores/metadata';
export let item: Interface;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={InterfaceDisplay} />
<ListSelectItem {item} {context} displayComponent={InterfaceDisplay} />
@@ -2,12 +2,17 @@
import { entities, serviceDefinitions } from '$lib/shared/stores/metadata';
import { getServiceForBinding } from '$lib/features/services/store';
export const PortBindingDisplay: EntityDisplayComponent<PortBinding> = {
interface ServiceAndHost {
service: Service;
host: Host;
}
export const PortBindingDisplay: EntityDisplayComponent<PortBinding, ServiceAndHost> = {
getId: (binding: PortBinding) => binding.id,
getLabel: (binding: PortBinding) => {
const port = get(getPortFromId(binding.port_id));
getLabel: (binding: PortBinding, context) => {
const port = context?.host.ports.find((p) => p.id == binding.port_id);
const iface = binding.interface_id
? get(getInterfaceFromId(binding.interface_id))
? context?.host.interfaces.find((i) => i.id == binding.interface_id)
: ALL_INTERFACES;
const portFormatted = port ? formatPort(port) : 'Unknown Port';
const interfaceFormatted = iface ? formatInterface(iface) : 'Unknown Interface';
@@ -30,7 +35,7 @@
binding: PortBinding,
onUpdate: (updates: Partial<PortBinding>) => void,
formApi: FormApi,
context: { service?: Service; host?: Host }
context
) => {
return {
component: Layer4BindingInlineEditor,
@@ -49,7 +54,7 @@
<script lang="ts">
import type { EntityDisplayComponent } from '../types';
import ListSelectItem from '../ListSelectItem.svelte';
import { formatInterface, getInterfaceFromId, getPortFromId } from '$lib/features/hosts/store';
import { formatInterface } from '$lib/features/hosts/store';
import { formatPort } from '$lib/shared/utils/formatting';
import type { PortBinding, Service } from '$lib/features/services/types/base';
import { Link2 } from 'lucide-svelte';
@@ -59,6 +64,7 @@
import type { FormApi } from '../../types';
export let item: PortBinding;
export let context: ServiceAndHost;
</script>
<ListSelectItem {item} displayComponent={PortBindingDisplay} />
<ListSelectItem {item} {context} displayComponent={PortBindingDisplay} />
@@ -4,7 +4,7 @@
import { entities, ports } from '$lib/shared/stores/metadata';
import type { Service } from '$lib/features/services/types/base';
export const PortDisplay: EntityDisplayComponent<Port> = {
export const PortDisplay: EntityDisplayComponent<Port, object> = {
getId: (port: Port) => `${port.id}`,
getLabel: (port: Port) => {
let metadata = ports.getMetadata(port.type);
@@ -59,6 +59,7 @@
import { get } from 'svelte/store';
export let item: Port;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={PortDisplay} />
<ListSelectItem {item} {context} displayComponent={PortDisplay} />
@@ -1,5 +1,5 @@
<script lang="ts" context="module">
export const PortTypeDisplay: EntityDisplayComponent<TypeMetadata<PortTypeMetadata>> = {
export const PortTypeDisplay: EntityDisplayComponent<TypeMetadata<PortTypeMetadata>, object> = {
getId: (portType: TypeMetadata<PortTypeMetadata>) => portType.id,
getLabel: (portType: TypeMetadata<PortTypeMetadata>) =>
`${portType.metadata.number}/${portType.metadata.protocol.toLowerCase()} - ${portType.name}`,
@@ -19,6 +19,7 @@
import type { EntityDisplayComponent } from '../types';
export let item: TypeMetadata<PortTypeMetadata>;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={PortTypeDisplay} />
<ListSelectItem {item} {context} displayComponent={PortTypeDisplay} />
@@ -1,7 +1,7 @@
<script lang="ts" context="module">
import { entities, serviceDefinitions } from '$lib/shared/stores/metadata';
export const ServiceDisplay: EntityDisplayComponent<Service> = {
export const ServiceDisplay: EntityDisplayComponent<Service, object> = {
getId: (service: Service) => service.id,
getLabel: (service: Service) => service.name,
getDescription: (service: Service) => {
@@ -51,6 +51,7 @@
import { matchConfidenceLabel } from '$lib/shared/types';
export let item: Service;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={ServiceDisplay} />
<ListSelectItem {item} {context} displayComponent={ServiceDisplay} />
@@ -1,6 +1,7 @@
<script lang="ts" context="module">
export const ServiceTypeDisplay: EntityDisplayComponent<
TypeMetadata<ServicedDefinitionMetadata>
TypeMetadata<ServicedDefinitionMetadata>,
object
> = {
getId: (serviceType: TypeMetadata<ServicedDefinitionMetadata>) => serviceType.id,
getLabel: (serviceType: TypeMetadata<ServicedDefinitionMetadata>) => serviceType.name,
@@ -31,6 +32,7 @@
import type { EntityDisplayComponent } from '../types';
export let item: TypeMetadata<ServicedDefinitionMetadata>;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={ServiceTypeDisplay} />
<ListSelectItem {item} {context} displayComponent={ServiceTypeDisplay} />
@@ -1,5 +1,5 @@
<script lang="ts" context="module">
export const SubnetDisplay: EntityDisplayComponent<Subnet> = {
export const SubnetDisplay: EntityDisplayComponent<Subnet, object> = {
getId: (subnet: Subnet) => subnet.id,
getLabel: (subnet: Subnet) => subnet.name,
getDescription: (subnet: Subnet) => (subnet.name == subnet.cidr ? '' : subnet.cidr),
@@ -18,6 +18,7 @@
import type { Subnet } from '$lib/features/subnets/types/base';
export let item: Subnet;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={SubnetDisplay} />
<ListSelectItem {item} {context} displayComponent={SubnetDisplay} />
@@ -1,7 +1,7 @@
<script lang="ts" context="module">
import { entities, serviceDefinitions } from '$lib/shared/stores/metadata';
export const VirtualizationManagerServiceDisplay: EntityDisplayComponent<Service> = {
export const VirtualizationManagerServiceDisplay: EntityDisplayComponent<Service, object> = {
getId: (service: Service) => service.id,
getLabel: (service: Service) => service.name,
getDescription: (service: Service) => {
@@ -47,6 +47,7 @@
import { hosts } from '$lib/features/hosts/store';
export let item: Service;
export let context = {};
</script>
<ListSelectItem {item} displayComponent={VirtualizationManagerServiceDisplay} />
<ListSelectItem {item} {context} displayComponent={VirtualizationManagerServiceDisplay} />
@@ -2,26 +2,19 @@ import type { Component } from 'svelte';
import type { TagProps } from '../../data/types';
import type { IconComponent } from '$lib/shared/utils/types';
import type { FormApi } from '../types';
export interface EntityDisplayComponent<T> {
// @typescript-eslint/no-explicit-any
export interface EntityDisplayComponent<T, C> {
// Required methods
getId(item: T): string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getLabel(item: T, context?: Record<string, any>): string;
getLabel(item: T, context?: C): string;
// Optional methods with defaults
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getDescription?(item: T, context?: Record<string, any>): string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getIcon?(item: T, context?: Record<string, any>): IconComponent | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getIconColor?(item: T, context?: Record<string, any>): string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTags?(item: T, context?: Record<string, any>): TagProps[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getIsDisabled?(item: T, context?: Record<string, any>): boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getCategory?(item: T, context?: Record<string, any>): string | null;
getDescription?(item: T, context: C): string;
getIcon?(item: T, context: C): IconComponent | null;
getIconColor?(item: T, context: C): string | null;
getTags?(item: T, context: C): TagProps[];
getIsDisabled?(item: T, context: C): boolean;
getCategory?(item: T, context: C): string | null;
// Optional inline editing support
supportsInlineEdit?: boolean;
@@ -29,15 +22,10 @@ export interface EntityDisplayComponent<T> {
item: T,
onUpdate: (updates: Partial<T>) => void,
formApi: FormApi,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context?: Record<string, any>
context?: C
): {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: Component<any>;
props: Record<string, unknown>;
};
}
export interface DisplayComponentProps<T> {
item: T;
}