services frontend getting there

This commit is contained in:
Maya
2025-09-06 17:58:06 -04:00
parent d618bfbfee
commit 3504764fe8
17 changed files with 650 additions and 954 deletions
@@ -7,7 +7,6 @@
import { getDaemonDiscoveryState } from '../daemons/store';
import RichSelect from '$lib/shared/components/forms/RichSelect.svelte';
import { getNodeTargetString } from "../nodes/store";
import { nodeStatuses } from '$lib/shared/stores/registry';
import DaemonDiscoveryStatus from './DaemonDiscoveryStatus.svelte';
import { type TagProps } from '$lib/shared/components/data/types';
import { get } from 'svelte/store';
@@ -15,9 +14,6 @@
let selectedDaemonId: string | null = null;
$: discoveryData = getDaemonDiscoveryState(selectedDaemonId, get(sessions));
$: selectedDaemon = $daemons.find(daemon => daemon.id == selectedDaemonId);
$: selectedNode = $nodes.find(node => node.id == selectedDaemon?.node_id);
$: nodeStyle = nodeStatuses.getColor(selectedNode?.status || null);
// Auto-select daemon logic: prioritize daemon with active session, fallback to first daemon
$: if (!selectedDaemonId && $daemons.length > 0) {
@@ -50,17 +46,9 @@
value: d.id,
label: node?.name || `Daemon ${d.id.substring(0, 8)}`,
description: node ? `on ${getNodeTargetString(node?.target)}` : `Daemon ${d.id.substring(0, 8)}`,
status: node?.status
}
})}
getOptionId={(option) => option.value}
getOptionTags={(option): TagProps[] => {
return [{
label: option.status,
bgColor: nodeStyle.bg,
textColor: nodeStyle.text
}]
}}
onSelect={handleDaemonSelect} />
</div>
{/if}
@@ -2,12 +2,12 @@
import { Edit, Radar, Trash2 } from 'lucide-svelte';
import { getNodeTargetString } from '../store';
import type { Node } from '../types/base';
import { capabilities, nodeStatuses, nodeTypes } from '$lib/shared/stores/registry';
import GenericCard from '$lib/shared/components/data/GenericCard.svelte';
import type { Daemon } from '$lib/features/daemons/types/base';
import { getDaemonDiscoveryState } from '$lib/features/daemons/store';
import DaemonDiscoveryStatus from '$lib/features/discovery/DaemonDiscoveryStatus.svelte';
import { sessions } from '$lib/features/discovery/store';
import { services } from '$lib/shared/stores/registry';
export let node: Node;
export let daemon: Daemon | null;
@@ -26,12 +26,8 @@
// Build card data
$: cardData = {
title: node.name,
subtitle: nodeTypes.getDisplay(node.node_type),
status: node.monitoring_interval == 0 ? 'Monitoring Disabled' : nodeStatuses.getDisplay(node.status || null),
statusColor: node.monitoring_interval == 0 ? 'text-gray-400' : nodeStatuses.getColor(node.status || null).text,
icon: nodeTypes.getIconComponent(node.node_type),
iconColor: 'text-blue-400',
icon: services.getIconComponent(node.services[0].type),
sections: connectionInfo ? [{
label: 'Connection',
value: connectionInfo
@@ -39,12 +35,11 @@
lists: [
{
label: 'Capabilities',
items: node.capabilities.map(cap => {
const [capId, capInfo] = Object.entries(cap)[0];
label: 'Services',
items: node.services.map(cap => {
return ({
id: capId,
label: capabilities.getDisplay(capId),
id: cap.type,
label: services.getDisplay(cap.type),
bgColor: 'bg-purple-900/30',
color: 'text-purple-300'
})
@@ -5,33 +5,28 @@
import { required } from 'svelte-forms/validators';
import type { Node } from '$lib/features/nodes/types/base';
import { maxLength, validNodeType } from '$lib/shared/components/forms/validators';
import { nodeTargets, nodeTypes } from '$lib/shared/stores/registry';
import { nodeTargets } from '$lib/shared/stores/registry';
export let form: any;
export let formData: Node;
export let isEditing: boolean = false;
// Create form fields with validation
const name = field('name', formData.name, [required(), maxLength(100)]);
const description = field('description', formData.description || '', [maxLength(500)]);
const nodeType = field('node_type', formData.node_type, [validNodeType(isEditing)]);
const hostname = field('hostname', formData.hostname || '');
// Add fields to form
onMount(() => {
form.name = name
form.description = description
form.nodeType = nodeType
form.hostname = hostname
});
// Update formData when field values change
$: formData.name = $name.value;
$: formData.description = $description.value;
$: formData.node_type = $nodeType.value;
$: formData.hostname = $hostname.value;
$: nodeTypeOptions = nodeTypes.getItems().map(t => {return {value:t.id, label: t.display_name}});
$: targetTypeOptions = nodeTargets.getItems().map(t => {return {value:t.id, label: t.display_name, description: t.description, icon: t.icon}});
// Initialize target if needed
@@ -91,36 +86,6 @@
</p>
</div>
<!-- Node Type -->
<div class="space-y-2">
<label for="node_type" class="block text-sm font-medium text-gray-300">
Node Type
{#if !isEditing}
<span class="text-red-400 ml-1">*</span>
{/if}
</label>
<select
id="node_type"
bind:value={$nodeType.value}
class="w-full px-3 py-2 bg-gray-700 border rounded-md text-white
focus:outline-none focus:ring-2 focus:ring-blue-500
{$nodeType.errors.length > 0 ? 'border-red-500' : 'border-gray-600'}"
>
{#each nodeTypeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
{#if $nodeType.errors.length > 0}
<div class="flex items-center gap-2 text-red-400">
<AlertCircle size={16} />
<p class="text-xs">{$nodeType.errors[0]}</p>
</div>
{/if}
<p class="text-xs text-gray-400">
Classification for this node type
</p>
</div>
<!-- Hostname -->
<div class="space-y-2">
<label for="node_domain" class="block text-sm font-medium text-gray-300">
@@ -3,9 +3,10 @@
import type { Node } from "$lib/features/nodes/types/base";
import { createEmptyNodeFormData } from "$lib/features/nodes/store";
import DetailsForm from './Details/DetailsForm.svelte';
import CapabilitiesForm from './Services/ServicesForm.svelte';
import EditModal from '$lib/shared/components/forms/EditModal.svelte';
import SubnetsForm from './Subnets/SubnetsForm.svelte';
import ServicesForm from './Services/ServicesForm.svelte';
import { registry } from '$lib/shared/stores/registry';
export let node: Node | null = null;
export let isOpen = false;
@@ -27,9 +28,9 @@
description: 'Basic node information and connection details'
},
{
id: 'capabilities',
label: 'Capabilities',
icon: Shield,
id: 'services',
label: 'Services',
icon: Server,
description: 'Services and monitoring configuration'
},
{
@@ -100,14 +101,6 @@
}
}
}
// Create node context for capabilities form
$: nodeContext = {
node_id: formData.id || undefined,
node_type: formData.node_type,
capabilities: formData.capabilities,
target: formData.target
};
// Handle form-based submission for create flow with steps
function handleFormSubmit() {
@@ -163,7 +156,9 @@
{#each tabs as tab}
<button
type="button"
on:click={() => activeTab = tab.id}
on:click={() => {
activeTab = tab.id;
}}
class="py-4 px-1 border-b-2 font-medium text-sm transition-colors
{activeTab === tab.id
? 'border-blue-500 text-blue-400'
@@ -175,9 +170,15 @@
<span>{tab.label}</span>
<!-- Show capability count indicator -->
{#if tab.id === 'capabilities' && formData.capabilities.length > 0}
{#if tab.id === 'services' && formData.services.length > 0}
<span class="ml-1 px-1.5 py-0.5 text-xs bg-blue-600 text-white rounded-full">
{formData.capabilities.length}
{formData.services.length}
</span>
{/if}
{#if tab.id === 'subnets' && formData.subnets.length > 0}
<span class="ml-1 px-1.5 py-0.5 text-xs bg-blue-600 text-white rounded-full">
{formData.subnets.length}
</span>
{/if}
</div>
@@ -204,12 +205,11 @@
<DetailsForm
{form}
bind:formData={formData}
{isEditing}
/>
</div>
</div>
{:else if activeTab === 'capabilities'}
{:else if activeTab === 'services'}
<div class="h-full overflow-hidden">
{#if !isEditing}
<div class="p-6 pb-4 border-b border-gray-700 flex-shrink-0">
@@ -221,10 +221,9 @@
{/if}
<div class="flex-1 relative">
<CapabilitiesForm
<ServicesForm
{form}
bind:selectedCapabilities={formData.capabilities}
{nodeContext}
bind:formData={formData}
/>
</div>
</div>
@@ -1,326 +1,365 @@
<script lang="ts">
import { ChevronDown, ChevronRight, ToggleLeft, ToggleRight, AlertCircle } from 'lucide-svelte';
import { field } from 'svelte-forms';
import { getCapabilityConfig, getCapabilityType, updateCapabilityConfig, type Capability } from '$lib/features/capabilities/types/base';
import type { CapabilityConfigForm } from '$lib/features/capabilities/types/forms';
import { createStyle } from '$lib/shared/utils/styling';
import { criticalityLevels } from '$lib/shared/stores/registry';
import Tag from '$lib/shared/components/data/Tag.svelte';
import { capabilityName } from '$lib/shared/components/forms/validators';
import DynamicField from '$lib/shared/components/forms/DynamicField.svelte';
import { required } from 'svelte-forms/validators';
import { AlertCircle, Plus, Trash2 } from 'lucide-svelte';
import type { Service, Port, Endpoint } from '$lib/features/services/types/base';
import { getServiceDisplayName, formatServicePorts } from '$lib/features/services/types/base';
import { registry } from '$lib/shared/stores/registry';
import Tag from '$lib/shared/components/data/Tag.svelte';
export let form: any;
export let capability: Capability | null = null;
export let schema: CapabilityConfigForm | null = null;
export let onChange: (updatedCapability: Capability) => void = () => {};
let expandedSections: Set<string> = new Set();
let capabilityNameField: any;
let capabilityConfig: Record<string, any> = {};
// Initialize form fields for capability name
$: if (capability && schema) {
const config = getCapabilityConfig(capability);
const isSystemAssigned = config.system_assigned;
capabilityNameField = field(
`capability_name_${getCapabilityType(capability)}`,
config.name,
[capabilityName(isSystemAssigned)]
export let service: Service | null = null;
export let onChange: (updatedService: Service) => void = () => {};
let serviceNameField: any;
let confirmedField: any;
// Get service metadata from registry
$: serviceMetadata = service ? $registry?.services?.find(s => s.id === service.type) : null;
// Validators
const serviceNameValidator = () => (value: string) => {
if (!value || value.trim().length === 0) {
return { name: 'required', valid: false };
}
return { name: 'valid', valid: true };
};
// Initialize form fields when service changes
$: if (service && serviceMetadata) {
serviceNameField = field(
`service_name_${service.type}`,
service.name,
[serviceNameValidator()]
);
if (form && !isSystemAssigned) {
form[`capability_name_${getCapabilityType(capability)}`] = capabilityNameField
confirmedField = field(
`service_confirmed_${service.type}`,
service.confirmed,
[]
);
// Register with parent form
if (form) {
form[`service_name_${service.type}`] = serviceNameField;
form[`service_confirmed_${service.type}`] = confirmedField;
}
}
// Initialize form data when capability changes
$: if (capability) {
const config = getCapabilityConfig(capability);
capabilityConfig = { ...config };
// Update service when field values change
$: if (service && serviceNameField && confirmedField && $serviceNameField && $confirmedField) {
const updatedService: Service = {
...service,
name: $serviceNameField.value,
confirmed: $confirmedField.value
};
// Update capability name field value if it exists
if (capabilityNameField && $capabilityNameField.value !== config.name) {
capabilityNameField.set(config.name);
// Only trigger onChange if values actually changed
if (updatedService.name !== service.name || updatedService.confirmed !== service.confirmed) {
onChange(updatedService);
}
}
// Update capability when form data changes
$: if (capability && schema) {
const currentConfig = getCapabilityConfig(capability);
const newName = capabilityNameField ? $capabilityNameField.value : currentConfig.name;
const hasNameChanged = newName !== currentConfig.name;
const hasConfigChanged = JSON.stringify(capabilityConfig) !== JSON.stringify(currentConfig);
// Port management functions
function addPort() {
if (!service) return;
if (hasNameChanged || hasConfigChanged) {
const updatedCapability = updateCapabilityConfig(capability, {
...capabilityConfig,
name: newName
});
onChange(updatedCapability);
}
const newPort: Port = {
number: 80,
tcp: true,
udp: false
};
const updatedService = {
...service,
ports: [...service.ports, newPort]
};
onChange(updatedService);
}
function toggleSection(sectionId: string) {
if (expandedSections.has(sectionId)) {
expandedSections.delete(sectionId);
} else {
expandedSections.add(sectionId);
}
expandedSections = expandedSections; // Trigger reactivity
function updatePort(index: number, updates: Partial<Port>) {
if (!service) return;
const updatedPorts = [...service.ports];
updatedPorts[index] = { ...updatedPorts[index], ...updates };
const updatedService = {
...service,
ports: updatedPorts
};
onChange(updatedService);
}
function updateTest(sectionIndex: number, updates: Partial<{
enabled: boolean;
criticality: string;
config: Record<string, any>;
}>) {
if (!capability || !schema) return;
function removePort(index: number) {
if (!service) return;
const capabilityConfig = getCapabilityConfig(capability);
// const updatedTests = [...capabilityConfig.tests];
// const section = schema.test_sections[sectionIndex];
const updatedService = {
...service,
ports: service.ports.filter((_, i) => i !== index)
};
// // Ensure test exists with proper structure
// if (!updatedTests[sectionIndex]) {
// updatedTests[sectionIndex] = {
// test: {
// type: section.test_type,
// config: getTestConfigFromSchema(section)
// },
// criticality: section.test_fields.find(f => f.id === 'criticality')?.default_value || 'Important',
// enabled: section.enabled_by_default
// };
// }
// Apply updates
// if (updates.enabled !== undefined) {
// updatedTests[sectionIndex].enabled = updates.enabled;
// }
// if (updates.criticality !== undefined) {
// updatedTests[sectionIndex].criticality = updates.criticality;
// }
// if (updates.config) {
// updatedTests[sectionIndex].test.config = {
// ...updatedTests[sectionIndex].test.config,
// ...updates.config
// };
// }
// Update the capability
const updatedCapability = updateCapabilityConfig(capability, {
...capabilityConfig,
// tests: updatedTests
});
onChange(updatedCapability);
onChange(updatedService);
}
function toggleTest(sectionIndex: number) {
if (!capability || !schema) return;
const config = getCapabilityConfig(capability);
const currentTest = config.tests[sectionIndex];
const newEnabledState = currentTest ? !currentTest.enabled : true;
updateTest(sectionIndex, { enabled: newEnabledState });
// Event handlers for port fields
function handlePortNumberChange(index: number, event: Event) {
const target = event.target as HTMLInputElement;
const portNumber = parseInt(target.value) || 80;
updatePort(index, { number: portNumber });
}
function updateTestConfig(sectionIndex: number, fieldId: string, value: any) {
if (fieldId === 'criticality') {
updateTest(sectionIndex, { criticality: value });
} else {
// This is a test config field
updateTest(sectionIndex, {
config: { [fieldId]: value }
});
}
function handleTcpChange(index: number, event: Event) {
const target = event.target as HTMLInputElement;
updatePort(index, { tcp: target.checked });
}
function handleUdpChange(index: number, event: Event) {
const target = event.target as HTMLInputElement;
updatePort(index, { udp: target.checked });
}
// Endpoint management functions
function addEndpoint() {
if (!service) return;
const newEndpoint: Endpoint = {
path: "/"
};
const updatedService = {
...service,
endpoints: [...service.endpoints, newEndpoint]
};
onChange(updatedService);
}
function updateEndpoint(index: number, updates: Partial<Endpoint>) {
if (!service) return;
const updatedEndpoints = [...service.endpoints];
updatedEndpoints[index] = { ...updatedEndpoints[index], ...updates };
const updatedService = {
...service,
endpoints: updatedEndpoints
};
onChange(updatedService);
}
function removeEndpoint(index: number) {
if (!service) return;
const updatedService = {
...service,
endpoints: service.endpoints.filter((_, i) => i !== index)
};
onChange(updatedService);
}
// Event handler for endpoint path changes
function handleEndpointPathChange(index: number, event: Event) {
const target = event.target as HTMLInputElement;
updateEndpoint(index, { path: target.value });
}
</script>
{#if !capability || !schema}
<div class="flex-1 min-h-0 flex items-center justify-center text-gray-400">
<div class="text-center">
<div class="text-lg mb-2">No capability selected</div>
<div class="text-sm">Select a capability from the list to configure it</div>
</div>
</div>
{:else}
{@const config = getCapabilityConfig(capability)}
<div class="h-full flex flex-col min-h-0">
<!-- Header -->
<div class="border-b border-gray-600 pb-4 mb-6">
<div class="flex items-center gap-3 mb-2">
{#if schema.capability_info.icon}
{@const iconStyle = createStyle(schema.capability_info.color, schema.capability_info.icon)}
<svelte:component this={iconStyle.IconComponent} class="w-6 h-6 {iconStyle.colors.icon}" />
{/if}
<h3 class="text-lg font-medium text-white">
{schema.capability_info.display_name}
</h3>
{#if service && serviceMetadata}
<div class="space-y-6">
<!-- Service Info Header -->
<div class="border-b border-gray-600 pb-4">
<div class="flex gap-3 pb-1">
<h3 class="text-lg font-medium text-white">{serviceMetadata.display_name}</h3>
<Tag
label={serviceMetadata.category}
color={serviceMetadata.color}/>
</div>
<p class="text-sm text-gray-400">{serviceMetadata.description}</p>
<div class="flex items-center gap-2 mt-2">
</div>
{#if schema.capability_info.description}
<p class="text-sm text-gray-400">{schema.capability_info.description}</p>
{/if}
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-auto space-y-6 min-h-0">
<!-- Capability Name -->
{#if !getCapabilityConfig(capability).system_assigned && capabilityNameField}
<!-- Basic Configuration -->
<div class="space-y-4">
<!-- Service Name Field -->
{#if serviceNameField}
<div class="space-y-2">
<label for="capability_name" class="block text-sm font-medium text-gray-300">
Name <span class="text-red-400 ml-1">*</span>
<label for="service_name" class="block text-sm font-medium text-gray-300">
Service Name <span class="text-red-400">*</span>
</label>
<input
id="capability_name"
id="service_name"
type="text"
bind:value={$capabilityNameField.value}
class="w-full px-3 py-2 bg-gray-700 border rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500
{$capabilityNameField.errors.length > 0 ? 'border-red-500' : 'border-gray-600'}"
bind:value={$serviceNameField.value}
class="w-full px-3 py-2 bg-gray-700 border rounded-md text-white
focus:outline-none focus:ring-2 focus:ring-blue-500
{$serviceNameField.errors.length > 0 ? 'border-red-500' : 'border-gray-600'}"
placeholder="Enter a descriptive name..."
/>
{#if $capabilityNameField.errors.length > 0}
{#if $serviceNameField.errors.length > 0}
<div class="flex items-center gap-2 text-red-400">
<AlertCircle size={16} />
<p class="text-xs">{$capabilityNameField.errors[0]}</p>
<p class="text-xs">{$serviceNameField.errors[0]}</p>
</div>
{/if}
<p class="text-xs text-gray-400">
Give this capability a meaningful name like "API Server" or "Admin Panel"
Give this service a meaningful name like "Main Web Server" or "Internal API"
</p>
</div>
{/if}
<!-- Capability Configuration Fields -->
{#if schema.capability_fields.length > 0}
<div>
<h4 class="text-sm font-medium text-gray-300 mb-4">Configuration</h4>
<div class="space-y-4">
{#each schema.capability_fields as field}
<DynamicField
{form}
{field}
fieldId={`${getCapabilityType(capability)}_${field.id}`}
value={capabilityConfig[field.id]}
onUpdate={(value: any) => capabilityConfig[field.id] = value}
/>
{/each}
</div>
<!-- Confirmed Status -->
{#if confirmedField}
<div class="space-y-2">
<label class="flex items-center gap-3">
<input
type="checkbox"
bind:checked={$confirmedField.value}
class="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
/>
<span class="text-sm font-medium text-gray-300">Service Confirmed</span>
</label>
<p class="text-xs text-gray-400 ml-7">
Mark as confirmed if you've verified this service is actually running
</p>
</div>
{/if}
<!-- Tests -->
<!-- {#if schema.test_sections.length > 0}
<div>
<h4 class="text-sm font-medium text-gray-300 mb-4">Tests</h4>
<div class="space-y-4">
{#each schema.test_sections as section, sectionIndex}
{@const isExpanded = expandedSections.has(section.test_type)}
{@const testConfig = config.tests[sectionIndex]}
{@const testStyle = createStyle(section.test_info.color, section.test_info.icon)}
<div class="border border-gray-600 rounded-lg overflow-hidden">
<div class="p-4 bg-gray-700/50">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<button
type="button"
on:click={() => toggleTest(sectionIndex)}
class="flex items-center"
title={testConfig?.enabled ? 'Disable test' : 'Enable test'}
>
{#if testConfig?.enabled}
<ToggleRight class="w-8 h-8 text-green-400" />
{:else}
<ToggleLeft class="w-8 h-8 text-gray-500" />
{/if}
</button>
<div class="flex items-center gap-3">
<svelte:component this={testStyle.IconComponent} class="w-8 h-8 {testStyle.colors.icon}" />
<div class="flex-col">
<div class="flex items-center gap-2">
<span class="font-medium text-white">{section.test_info.display_name}</span>
<Tag
bgColor={criticalityLevels.getColor(testConfig?.criticality || 'Important').bg}
textColor={criticalityLevels.getColor(testConfig?.criticality || 'Important').text}
label={testConfig?.criticality || 'Important'} />
</div>
<span class="text-sm text-gray-400">{section.test_info.description}</span>
</div>
</div>
</div>
<button
type="button"
on:click={() => toggleSection(section.test_type)}
class="p-1 text-gray-400 hover:text-white hover:bg-gray-600 rounded"
title={isExpanded ? 'Collapse' : 'Expand'}
>
{#if isExpanded}
<ChevronDown class="w-4 h-4" />
{:else}
<ChevronRight class="w-4 h-4" />
{/if}
</button>
</div>
{#if section.description}
<p class="text-sm text-gray-400 mt-2">{section.description}</p>
{/if}
</div>
<!-- Ports Configuration -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-gray-300">Ports</h4>
<button
type="button"
on:click={addPort}
class="flex items-center gap-2 px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
<Plus size={14} />
Add Port
</button>
</div>
{#if service.ports.length === 0}
<div class="text-center py-4 text-gray-400 text-sm">
No ports configured. Click "Add Port" to add one.
</div>
{:else}
<div class="space-y-3">
{#each service.ports as port, index}
<div class="flex items-center gap-3 p-3 bg-gray-700/30 rounded-lg">
<div class="flex-1 grid grid-cols-3 gap-3">
<div>
<div class="block text-xs font-medium text-gray-400 mb-1">Port Number</div>
<input
type="number"
min="1"
max="65535"
value={port.number}
on:input={(e) => handlePortNumberChange(index, e)}
class="w-full px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white focus:ring-2 focus:ring-blue-500"
/>
</div>
{#if isExpanded}
<div class="p-4 border-t border-gray-600 bg-gray-800/30">
<div class="space-y-4">
{#each section.test_fields as field}
<DynamicField
{form}
{field}
fieldId={`${getCapabilityType(capability)}_${section.test_type}_${field.id}`}
value={testConfig?.[field.id as keyof typeof testConfig]}
onUpdate={(value: any) => updateTestConfig(sectionIndex, field.id, value)}
disabled={!testConfig?.enabled}
/>
{/each}
</div>
<div>
<div class="block text-xs font-medium text-gray-400 mb-1">Protocol</div>
<div class="flex gap-2">
<label class="flex items-center gap-1">
<input
type="checkbox"
checked={port.tcp}
on:change={(e) => handleTcpChange(index, e)}
class="w-3 h-3 text-blue-600 bg-gray-700 border-gray-600 rounded"
/>
<span class="text-xs text-gray-300">TCP</span>
</label>
<label class="flex items-center gap-1">
<input
type="checkbox"
checked={port.udp}
on:change={(e) => handleUdpChange(index, e)}
class="w-3 h-3 text-blue-600 bg-gray-700 border-gray-600 rounded"
/>
<span class="text-xs text-gray-300">UDP</span>
</label>
</div>
{/if}
</div>
<div class="flex items-end">
<button
type="button"
on:click={() => removePort(index)}
class="flex items-center gap-1 px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
>
<Trash2 size={12} />
Remove
</button>
</div>
</div>
{/each}
</div>
</div>
{/if} -->
<!-- Warnings and Errors -->
{#if schema.warnings.length > 0}
<div class="rounded-lg bg-yellow-900/20 border border-yellow-600 p-4">
<h5 class="text-sm font-medium text-yellow-400 mb-2">Warnings</h5>
<div class="space-y-1">
{#each schema.warnings as warning}
<p class="text-sm text-yellow-300">{warning.message}</p>
{/each}
</div>
</div>
{/each}
</div>
{/if}
{#if schema.errors.length > 0}
<div class="rounded-lg bg-red-900/20 border border-red-600 p-4">
<h5 class="text-sm font-medium text-red-400 mb-2">Errors</h5>
<div class="space-y-1">
{#each schema.errors as error}
<p class="text-sm text-red-300">{error.message}</p>
{/each}
</div>
</div>
<!-- Endpoints Configuration -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium text-gray-300">Endpoints</h4>
<button
type="button"
on:click={addEndpoint}
class="flex items-center gap-2 px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
<Plus size={14} />
Add Endpoint
</button>
</div>
{#if service.endpoints.length === 0}
<div class="text-center py-4 text-gray-400 text-sm">
No endpoints configured. Click "Add Endpoint" to add one.
</div>
{:else}
<div class="space-y-3">
{#each service.endpoints as endpoint, index}
<div class="flex items-center gap-3 p-3 bg-gray-700/30 rounded-lg">
<div class="flex-1 grid grid-cols-2 gap-3">
<div>
<div class="block text-xs font-medium text-gray-400 mb-1">Path</div>
<input
type="text"
value={endpoint.path || ""}
on:input={(e) => handleEndpointPathChange(index, e)}
placeholder="/api/health"
class="w-full px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white focus:ring-2 focus:ring-blue-500"
/>
</div>
<div class="flex items-end">
<button
type="button"
on:click={() => removeEndpoint(index)}
class="flex items-center gap-1 px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700"
>
<Trash2 size={12} />
Remove
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{:else}
<div class="flex-1 min-h-0 flex items-center justify-center text-gray-400">
<div class="text-center">
<div class="text-lg mb-2">No service selected</div>
<div class="text-sm">Select a service from the list to configure it</div>
</div>
</div>
{/if}
@@ -1,183 +1,186 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Capability, CapabilityConfig } from '$lib/features/capabilities/types/base';
import type { NodeContext } from '$lib/features/nodes/types/base';
import type { CapabilityConfigForm } from '$lib/features/capabilities/types/forms';
import { getCapabilityConfig, getCapabilityType, createCapability } from '$lib/features/capabilities/types/base';
import CapabilitiesConfigPanel from './ServicesConfigPanel.svelte';
import { capabilities } from '$lib/shared/stores/registry';
import { createStyle } from '$lib/shared/utils/styling';
import { getCapabilityForms } from '$lib/features/capabilities/store';
import { loading, pushError } from '$lib/shared/stores/feedback';
import ListConfigEditor from '$lib/shared/components/forms/ListConfigEditor.svelte';
import ServicesConfigPanel from './ServicesConfigPanel.svelte';
import { createStyle } from '$lib/shared/utils/styling';
import type { Port, Service } from '$lib/features/services/types/base';
import { createDefaultService, formatServicePorts } from '$lib/features/services/types/base';
import type { Node } from '$lib/features/nodes/types/base';
import { registry, services } from '$lib/shared/stores/registry';
import type { TypeMetadata } from '$lib/shared/stores/registry';
import type { TagProps } from '$lib/shared/components/data/types';
export let form: any;
export let selectedCapabilities: Capability[] = [];
export let nodeContext: NodeContext;
export let formData: Node;
let availableSchemas: Record<string, CapabilityConfigForm> = {};
// Computed values
$: nodeServices = formData.services || [];
$: availableServiceTypes = $registry?.services?.filter(service =>
service.metadata?.can_be_added !== false
).sort((a, b) => a.category.localeCompare(b.category, 'en')) || [];
// Available capability types for dropdown
$: capabilitySelectOptions = Object.keys(availableSchemas)
.filter(type => !availableSchemas[type]?.system_assigned);
// Event handlers
function handleAddService(serviceTypeId: string) {
const serviceMetadata = $registry?.services?.find(s => s.id === serviceTypeId);
if (!serviceMetadata) return;
const defaultPorts = serviceMetadata.metadata?.default_ports || [];
const defaultEndpoints = serviceMetadata.metadata?.default_endpoints || [];
const newService = createDefaultService(
serviceTypeId,
serviceMetadata.display_name,
defaultPorts,
defaultEndpoints
);
formData.services = [...nodeServices, newService];
}
function handleServiceChange(service: Service, index: number) {
if (index >= 0 && index < nodeServices.length) {
const updatedServices = [...nodeServices];
updatedServices[index] = service;
formData.services = updatedServices;
}
}
function handleRemoveService(index: number) {
formData.services = nodeServices.filter((_, i) => i !== index);
}
// Display functions for options (available service types)
function getOptionId(serviceMetadata: TypeMetadata): string {
return serviceMetadata.id;
}
async function loadCapabilitySchemas() {
try {
const response = await getCapabilityForms({
node_context: nodeContext
function getOptionCategory(serviceMetadata: TypeMetadata): string {
return serviceMetadata.category;
}
function getOptionLabel(serviceMetadata: TypeMetadata): string {
return serviceMetadata.display_name;
}
function getOptionDescription(serviceMetadata: TypeMetadata): string {
return serviceMetadata.description;
}
function getOptionIcon(serviceMetadata: TypeMetadata) {
return createStyle(null, serviceMetadata.icon).IconComponent;
}
function getOptionIconColor(serviceMetadata: TypeMetadata) {
return createStyle(serviceMetadata.color, null).colors.icon;
}
function getOptionTags(serviceMetadata: TypeMetadata) {
const tags = [];
const defaultPorts = serviceMetadata.metadata?.default_ports || [];
if (defaultPorts.length > 0) {
const portTags = defaultPorts.map((p: Port) => {
return {
label: `${p.number}${p.tcp && p.udp ? '/tcp+udp' : p.tcp ? '/tcp' : '/udp'}`,
color:"blue"
}}
);
console.log(portTags)
tags.push(...portTags)
}
return tags;
}
// Display functions for items (current services)
function getItemId(service: Service): string {
return `${service.type}_${service.name}`;
}
function getItemLabel(service: Service): string {
return service.name;
}
function getItemDescription(service: Service): string {
return [`${formatServicePorts(service.ports)}`].filter(Boolean).join(' • ');
}
function getItemIcon(service: Service) {
const serviceMetadata = $registry?.services?.find(s => s.id === service.type);
if (serviceMetadata) {
return createStyle(null, serviceMetadata.icon).IconComponent;
}
return createStyle(null, 'Monitor').IconComponent;
}
function getItemIconColor(service: Service) {
const serviceMetadata = $registry?.services?.find(s => s.id === service.type);
if (serviceMetadata) {
return createStyle(serviceMetadata.color, null).colors.icon;
}
return createStyle('gray', null).colors.icon;
}
function getItemTags(service: Service) {
const tags: TagProps[] = [];
const serviceMetadata = $registry?.services?.find(s => s.id === service.type);
// if (serviceMetadata) {
// tags.push({
// label: serviceMetadata.category,
// color: serviceMetadata.color
// });
// }
if (!service.confirmed) {
tags.push({
label: "Unconfirmed",
color: "yellow"
});
if (response && response?.data) {
availableSchemas = response.data;
}
} catch (err) {
pushError(err instanceof Error ? err.message : 'Failed to load capability schemas')
}
}
function capabilityFromType(capabilityType: string) {
const schema = availableSchemas[capabilityType]
const baseConfig = {
name: capabilities.getDisplay(capabilityType),
// tests: schema?.test_sections?.map(section => ({
// test: {
// type: section.test_type,
// config: getTestConfigFromSchema(section)
// },
// criticality: section.test_fields.find(f => f.id === 'criticality')?.default_value || 'Important',
// enabled: section.enabled_by_default ?? false
// })) || [],
system_assigned: schema?.system_assigned ?? false,
port: undefined,
process: undefined,
discovery_ports: undefined
};
let config: CapabilityConfig = { ...baseConfig };
// Add capability-specific default values from schema
schema?.capability_fields?.forEach(field => {
if (field.default_value !== undefined) {
config[field.id] = field.default_value;
}
});
return createCapability(capabilityType, config);
}
function handleAddCapability(capabilityType: string) {
console.log('handleAddCapability called with:', capabilityType);
const newCapability = capabilityFromType(capabilityType);
console.log('Created capability:', newCapability);
selectedCapabilities = [...selectedCapabilities, newCapability];
console.log('Updated selectedCapabilities length:', selectedCapabilities.length);
}
function handleCapabilityChange(updatedCapability: Capability, index: number) {
if (index >= 0 && index < selectedCapabilities.length) {
selectedCapabilities[index] = updatedCapability;
selectedCapabilities = selectedCapabilities; // Trigger reactivity
}
}
// Display functions for options (dropdown)
function getOptionId(capabilityType: string): string {
return capabilityType;
}
function getOptionLabel(capabilityType: string): string {
return capabilities.getDisplay(capabilityType);
}
function getOptionDescription(capabilityType: string): string {
return capabilities.getDescription(capabilityType);
}
function getOptionIcon(capabilityType: string) {
return createStyle(null, capabilities.getIcon(capabilityType)).IconComponent;
}
function getOptionIconColor(capabilityType: string) {
return capabilities.getColor(capabilityType).icon;
}
// Display functions for items (list)
function getItemId(capability: Capability): string {
return getCapabilityType(capability);
}
function getItemLabel(capability: Capability): string {
const config = getCapabilityConfig(capability);
return config.name || 'Unnamed Capability';
}
function getItemDescription(capability: Capability): string {
const parts = [];
const type = getCapabilityType(capability);
const config = getCapabilityConfig(capability);
// Add capability type
parts.push(type);
// Add key config details
if (config.port) parts.push(`Port ${config.port}`);
if (config.path && config.path !== '/') parts.push(config.path);
if (config.hostname) parts.push(config.hostname);
return parts.join(' • ');
return tags;
}
function getItemIcon(capability: Capability) {
let iconName = capabilities.getIcon(getCapabilityType(capability));
return createStyle(null, iconName).IconComponent;
}
function getItemIconColor(capability: Capability) {
let colorStyle = capabilities.getColor(getCapabilityType(capability));
return colorStyle.icon;
}
onMount(() => {
loadCapabilitySchemas();
});
</script>
<ListConfigEditor
{form}
bind:items={selectedCapabilities}
options={capabilitySelectOptions}
loading={$loading}
label="Capabilities"
helpText="Configure services and their monitoring tests"
bind:items={formData.services}
options={availableServiceTypes}
label="Services"
helpText="Configure services running on this node"
emptyMessage="No services configured. Add one to get started."
allowDuplicates={true}
allowItemRemove={(selected) => !getCapabilityConfig(selected).system_assigned}
emptyMessage="No capabilities configured. Add one to get started."
allowReorder={true}
placeholder="Select service type to add..."
{getOptionId}
{getOptionLabel}
{getOptionDescription}
{getOptionIcon}
{getOptionIconColor}
{getOptionTags}
{getOptionCategory}
{getItemId}
{getItemLabel}
{getItemDescription}
{getItemIcon}
{getItemIconColor}
{getItemTags}
onAdd={handleAddCapability}
onChange={handleCapabilityChange}
onAdd={handleAddService}
onRemove={handleRemoveService}
onChange={handleServiceChange}
>
<CapabilitiesConfigPanel
<ServicesConfigPanel
slot="config"
let:selectedItem
let:selectedIndex
let:onChange
{form}
capability={selectedItem}
schema={selectedItem ? availableSchemas[getCapabilityType(selectedItem)] : null}
onChange={(updatedCapability) => onChange(updatedCapability)}
service={selectedItem}
onChange={(updatedService) => onChange(updatedService)}
/>
</ListConfigEditor>
+13 -19
View File
@@ -1,11 +1,9 @@
import { writable } from 'svelte/store';
import type { Node } from "./types/base";
import { api } from '../../shared/utils/api';
import { createPoller, type Poller } from '../../shared/utils/polling';
import type { NodeTarget } from './types/targets';
import { pushError, pushInfo, pushWarning } from '$lib/shared/stores/feedback';
import { pushInfo, pushWarning } from '$lib/shared/stores/feedback';
import { utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/utils/formatting';
import { testTypes } from '$lib/shared/stores/registry';
export const nodes = writable<Node[]>([]);
export const polling = writable(false);
@@ -57,14 +55,14 @@ export async function createNode(data: Node) {
interface UpdateNodeResponse {
node: Node,
capability_test_changes: Record<string, NodeCapabilityTestChange>,
// capability_test_changes: Record<string, NodeCapabilityTestChange>,
subnet_changes: NodeSubnetRelationshipChange
}
interface NodeCapabilityTestChange {
newly_compatible: string[],
incompatible: string[]
}
// interface NodeCapabilityTestChange {
// newly_compatible: string[],
// incompatible: string[]
// }
interface NodeSubnetRelationshipChange {
new_gateway: Subnet[],
@@ -80,12 +78,12 @@ export async function updateNode(data: Node) {
(updatedNodeResponse, current) => {
const updatedNode = updatedNodeResponse.node;
Object.keys(updatedNodeResponse.capability_test_changes).forEach(cap => {
let incompatible = updatedNodeResponse.capability_test_changes[cap].incompatible.map(i => testTypes.getDisplay(i))
let newly_compatible = updatedNodeResponse.capability_test_changes[cap].newly_compatible.map(n => testTypes.getDisplay(n))
incompatible.length > 0 ? pushWarning(`The following tests are no longer compatible with node "${updatedNode.name}" and have been removed: ${incompatible.join(", ")}`) : null
newly_compatible.length > 0 ? pushInfo(`The following tests are now compatible with node "${updatedNode.name}" and have been added: ${newly_compatible.join(", ")}`) : null
})
// Object.keys(updatedNodeResponse.capability_test_changes).forEach(cap => {
// let incompatible = updatedNodeResponse.capability_test_changes[cap].incompatible.map(i => testTypes.getDisplay(i))
// let newly_compatible = updatedNodeResponse.capability_test_changes[cap].newly_compatible.map(n => testTypes.getDisplay(n))
// incompatible.length > 0 ? pushWarning(`The following tests are no longer compatible with node "${updatedNode.name}" and have been removed: ${incompatible.join(", ")}`) : null
// newly_compatible.length > 0 ? pushInfo(`The following tests are now compatible with node "${updatedNode.name}" and have been added: ${newly_compatible.join(", ")}`) : null
// })
if (updatedNodeResponse.subnet_changes.new_dns_resolver.length > 0) {
pushInfo(`The following subnets now have node "${updatedNode.name}" set as a DNS resolver: ${
@@ -133,7 +131,6 @@ export function createEmptyNodeFormData(): Node {
created_at: utcTimeZoneSentinel,
updated_at: utcTimeZoneSentinel,
name: '',
status: 'Unknown',
description: '',
hostname: '',
target: {
@@ -142,14 +139,11 @@ export function createEmptyNodeFormData(): Node {
ip: '',
},
},
node_type: 'UnknownDevice',
capabilities: [],
services: [],
subnets: [],
monitoring_interval: 10,
last_seen: utcTimeZoneSentinel,
node_groups: [],
discovery_status: 'Manual',
dns_resolver_node_id: uuidv4Sentinel
};
}
+2 -12
View File
@@ -1,12 +1,6 @@
import type { Capability } from "$lib/features/capabilities/types/base";
import type { Service } from "$lib/features/services/types/base";
import type { NodeTarget } from "./targets";
export interface NodeContext {
node_id?: string;
node_type: string;
capabilities: Capability[];
target: any;
}
export interface Node {
id: string;
created_at: string;
@@ -16,15 +10,11 @@ export interface Node {
description: string;
hostname: string;
target: NodeTarget;
node_type: string;
capabilities: Capability[];
services: Service[];
subnets: NodeSubnetMembership[];
monitoring_interval: number;
node_groups: string[];
discovery_status: string;
status: string;
dns_resolver_node_id: string;
}
export type NodeCapability = {
+53
View File
@@ -0,0 +1,53 @@
// Frontend Service interface that matches the backend Service enum with serde(tag = "type")
export interface Port {
number: number;
udp: boolean;
tcp: boolean;
}
export interface Endpoint {
url?: string;
method?: string;
protocol?: string;
ip?: string;
port?: Port;
path?: string;
}
export interface Service {
// Service type (automatically added by serde tag)
type: string;
// Common fields shared by all service variants
confirmed: boolean;
name: string;
ports: Port[];
endpoints: Endpoint[];
// Optional daemon_id for NetvisorDaemon services
daemon_id?: string;
}
// Helper functions for working with services and the TypeRegistry
export function createDefaultService(serviceType: string, serviceName?: string, defaultPorts?: Port[], defaultEndpoints?: Endpoint[]): Service {
return {
type: serviceType,
confirmed: false,
name: serviceName || serviceType,
ports: defaultPorts ? [...defaultPorts] : [],
endpoints: defaultEndpoints ? [...defaultEndpoints] : []
};
}
export function getServiceDisplayName(service: Service): string {
return service.name || service.type;
}
export function formatServicePorts(ports: Port[]): string {
if (!ports || ports.length === 0) return "No ports";
return ports.map(p =>
`${p.number}${p.tcp && p.udp ? '/tcp+udp' : p.tcp ? '/tcp' : '/udp'}`
).join(', ');
}
+4 -5
View File
@@ -1,13 +1,12 @@
<script lang="ts">
import { createColorHelper } from "$lib/shared/utils/styling";
import { Circle } from "lucide-svelte";
import type { Component } from "svelte";
export let icon = Circle;
export let enableIcon = false
export let icon: Component | null = null;
export let color: string = 'gray';
export let bgColor: string = 'bg-gray-700/30';
export let textColor: string = 'text-gray-500';
export let textColor: string = 'text-gray-300';
export let disabled = false;
export let label: string;
@@ -25,7 +24,7 @@
<div class="items-center space-x-2">
{#if enableIcon }
{#if icon }
<svelte:component this={icon} size={16} class={textColor} />
{/if}
@@ -1,263 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { AlertCircle } from 'lucide-svelte';
import { field as svelteFormsField } from 'svelte-forms';
import { required } from 'svelte-forms/validators';
import type { ConfigField } from './types';
import { createStyle } from '$lib/shared/utils/styling';
import RichSelect from '$lib/shared/components/forms/RichSelect.svelte';
import { maxLength, portRange } from './validators';
export let form: any;
export let field: ConfigField;
export let fieldId: string;
export let value: any;
export let onUpdate: (value: any) => void;
export let disabled: boolean = false;
let formField: any;
let previousFieldId: string;
// Create form field with appropriate validators
function createFormField() {
const validators = [];
// Add required validator if needed
if (field.required) {
validators.push(required());
}
// Add type-specific validators
if (field.field_type.base_type === 'string' && field.field_type.constraints?.maxLength) {
validators.push(maxLength(field.field_type.constraints.maxLength));
}
if (field.field_type.base_type === 'integer') {
validators.push(portRange()); // Assuming integer fields are ports, customize as needed
}
return svelteFormsField(fieldId, getInitialValue(), validators);
}
function getInitialValue() {
// If a value is explicitly provided, use it
if (value !== undefined && value !== null && value !== '') {
return value;
}
// Otherwise, use the field's default value
if (field.default_value !== undefined && field.default_value !== null) {
return field.default_value;
}
// Fallback to empty string or appropriate default based on field type
switch (field.field_type.base_type) {
case 'boolean':
return false;
case 'integer':
return '';
case 'select':
case 'rich_select':
return '';
default:
return '';
}
}
// Clean up old form field and create new one
function initializeFormField() {
// Clean up old form field if it exists
if (formField && form && previousFieldId) {
delete form[previousFieldId];
}
// Create new form field
formField = createFormField();
// Register with parent form
if (form) {
form[fieldId] = formField;
}
previousFieldId = fieldId;
}
// Initialize form field when component mounts or fieldId changes
$: if (fieldId !== previousFieldId) {
initializeFormField();
}
// Initialize on mount
onMount(() => {
if (!formField) {
initializeFormField();
}
});
// Clean up on destroy
onDestroy(() => {
if (formField && form && fieldId) {
delete form[fieldId];
}
});
// Update parent when field value changes
$: if (formField && $formField) {
let processedValue = $formField.value;
// Type conversion based on field type
if (field.field_type.base_type === 'integer') {
if (processedValue === '') {
processedValue = '';
} else {
const parsed = parseInt(processedValue);
processedValue = isNaN(parsed) ? '' : parsed;
}
} else if (field.field_type.base_type === 'boolean') {
processedValue = Boolean(processedValue);
}
onUpdate(processedValue);
}
// Update field when external value changes
$: if (formField && value !== $formField.value && value !== undefined) {
formField.set(value);
}
function handleRichSelectChange(newValue: any) {
if (disabled || !formField) return;
formField.set(newValue);
}
// Disabled styling classes
$: disabledClass = disabled ? 'opacity-50 cursor-not-allowed' : '';
$: inputDisabledClass = disabled ? 'bg-gray-800 cursor-not-allowed' : 'bg-gray-700';
$: errorClass = formField && $formField.errors.length > 0 ? 'border-red-500' : 'border-gray-600';
</script>
{#if formField}
<div class="space-y-2 {disabledClass}">
<label for={fieldId} class="block text-sm font-medium text-gray-300">
{field.label}
{#if field.required}
<span class="text-red-400 ml-1">*</span>
{/if}
</label>
{#if field.field_type.base_type === 'string'}
<input
id={fieldId}
type="text"
bind:value={$formField.value}
placeholder={field.placeholder}
{disabled}
class="w-full px-3 py-2 {inputDisabledClass} border {errorClass} rounded-md text-white
focus:outline-none focus:ring-2 focus:ring-blue-500
{disabled ? 'text-gray-400' : ''}"
/>
{:else if field.field_type.base_type === 'integer'}
<input
id={fieldId}
type="number"
bind:value={$formField.value}
placeholder={field.placeholder}
min={field.field_type.constraints?.min}
max={field.field_type.constraints?.max}
step={field.field_type.constraints?.step || 1}
{disabled}
class="w-full px-3 py-2 {inputDisabledClass} border {errorClass} rounded-md text-white
focus:outline-none focus:ring-2 focus:ring-blue-500
{disabled ? 'text-gray-400' : ''}"
/>
{:else if field.field_type.base_type === 'boolean'}
<label class="flex items-center gap-3 cursor-pointer {disabled ? 'cursor-not-allowed' : ''}">
<input
id={fieldId}
type="checkbox"
bind:checked={$formField.value}
{disabled}
class="rounded {inputDisabledClass} border-gray-600 text-blue-600 focus:ring-blue-500
{disabled ? 'cursor-not-allowed' : ''}"
/>
<span class="text-sm {disabled ? 'text-gray-500' : 'text-gray-300'}">
{field.help_text || 'Enable this option'}
</span>
</label>
{:else if field.field_type.base_type === 'select'}
<select
id={fieldId}
bind:value={$formField.value}
{disabled}
class="w-full px-3 py-2 {inputDisabledClass} border {errorClass} rounded-md text-white
focus:outline-none focus:ring-2 focus:ring-blue-500
{disabled ? 'text-gray-400 cursor-not-allowed' : ''}"
>
<option value="">{field.placeholder || 'Select an option...'}</option>
{#each field.field_type.options || [] as option}
<option value={option.value} disabled={option.disabled}>
{option.label}
</option>
{/each}
</select>
{:else if field.field_type.base_type === 'rich_select'}
<RichSelect
selectedValue={$formField.value}
options={field.field_type.options?.map(opt => ({
value: opt.value,
label: opt.label,
description: opt.description,
disabled: opt.disabled || false,
metadata: opt.metadata
})) || []}
placeholder={field.placeholder || 'Select an option...'}
{disabled}
onSelect={handleRichSelectChange}
getOptionIcon={(opt) => {
if (opt.metadata?.icon) {
return createStyle(null, opt.metadata.icon).IconComponent;
}
return null;
}}
getOptionIconColor={(opt) => {
if (opt.metadata?.color) {
return createStyle(opt.metadata.color, null).colors.icon;
}
return 'text-gray-400';
}}
/>
{:else}
<!-- Fallback for unknown field types -->
<input
id={fieldId}
type="text"
bind:value={$formField.value}
placeholder={field.placeholder}
{disabled}
class="w-full px-3 py-2 {inputDisabledClass} border {errorClass} rounded-md text-white
focus:outline-none focus:ring-2 focus:ring-blue-500
{disabled ? 'text-gray-400' : ''}"
/>
{/if}
<!-- Help text -->
{#if field.help_text && field.field_type.base_type !== 'boolean'}
<p class="text-xs {disabled ? 'text-gray-500' : 'text-gray-400'}">
{field.help_text}
</p>
{/if}
<!-- Error message -->
{#if $formField.errors.length > 0}
<div class="flex items-center gap-2 text-red-400">
<AlertCircle size={16} />
<p class="text-xs">{$formField.errors[0]}</p>
</div>
{/if}
</div>
{/if}
@@ -1,107 +0,0 @@
<script lang="ts">
import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-svelte';
export let data: any;
export let title: string = "JSON Data";
export let maxHeight: string = "max-h-96";
export let initiallyExpanded: boolean = false;
export let showCopyButton: boolean = true;
export let indent: number = 2;
let isExpanded = initiallyExpanded;
let copySuccess = false;
$: prettyJson = JSON.stringify(data, null, indent);
$: jsonLines = prettyJson ? prettyJson.split('\n').length : 0;
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(prettyJson);
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 2000);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = prettyJson;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 2000);
}
}
function toggleExpanded() {
isExpanded = !isExpanded;
}
</script>
<div class="bg-gray-800 rounded-lg overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3">
<button
class="flex items-center gap-2 text-gray-300 hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
on:click={toggleExpanded}
type="button"
>
{#if isExpanded}
<ChevronDown size={16} class="text-gray-500 transition-transform" />
{:else}
<ChevronRight size={16} class="text-gray-500 transition-transform" />
{/if}
<span class="font-medium">{title}</span>
{#if !isExpanded && jsonLines}
<span class="text-xs text-gray-500 bg-gray-700 px-2 py-1 rounded-full">
{jsonLines} lines
</span>
{/if}
</button>
{#if showCopyButton}
<button
class="p-2 text-gray-400 hover:text-white hover:bg-gray-600 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
on:click={copyToClipboard}
title={copySuccess ? "Copied!" : "Copy to clipboard"}
type="button"
>
{#if copySuccess}
<Check size={16} class="text-green-400" />
{:else}
<Copy size={16} />
{/if}
</button>
{/if}
</div>
<!-- Content -->
{#if isExpanded}
<div class="relative">
<div class="p-4">
<div class="relative">
<pre
class="text-gray-300 text-sm font-mono whitespace-pre overflow-auto bg-gray-900 p-4 rounded-lg {maxHeight} scrollbar-thin scrollbar-track-gray-800 scrollbar-thumb-gray-600 hover:scrollbar-thumb-gray-500"
><code class="block leading-relaxed">{prettyJson}</code></pre>
<!-- Optional: Add line numbers -->
<!-- <div class="absolute top-0 left-0 p-4 text-xs text-gray-500 pointer-events-none select-none">
{#each Array(jsonLines) as _, i}
<div class="h-5 leading-relaxed">{i + 1}</div>
{/each}
</div> -->
</div>
</div>
<!-- Footer with metadata -->
<div class="px-4 pb-3 flex justify-between items-center text-xs text-gray-500" style="border: none;">
<span>{jsonLines} lines • {prettyJson.length} characters</span>
<span>JSON</span>
</div>
</div>
{/if}
</div>
@@ -33,6 +33,7 @@
export let getOptionIconColor: (option: TOption) => string = () => '';
export let getOptionTags: (option: TOption) => TagProps[] = () => [];
export let getOptionIsDisabled: (option: TOption) => boolean = () => false;
export let getOptionCategory: (item: any) => string | null = (item) => null;
// Display functions for items (list)
export let getItemId: (item: TItem) => string;
@@ -134,6 +135,7 @@
getOptionIconColor={getOptionIconColor}
getOptionTags={getOptionTags}
getOptionIsDisabled={getOptionIsDisabled}
{getOptionCategory}
getItemId={getItemId}
getItemLabel={getItemLabel}
@@ -29,6 +29,7 @@
export let getOptionLabel: (item: V) => string | null = (item) => null
export let getOptionDescription: (item: V) => string | null = (item) => null
export let getOptionIsDisabled: (item: V) => boolean = (item) => false
export let getOptionCategory: (item: any) => string | null = (item) => null;
// Items
export let items: T[] = [];
@@ -158,6 +159,7 @@
{getOptionDescription}
{getOptionTags}
{getOptionIsDisabled}
{getOptionCategory}
/>
</div>
</div>
@@ -27,20 +27,22 @@
<!-- Label and description -->
<div class="flex-1 min-w-0 text-left">
<span class="block truncate">{getLabel(item)}</span>
<div class="flex gap-3 pb-1">
<span class="block truncate">{getLabel(item)}</span>
<!-- Tag -->
{#if getTags}
{@const tags = getTags(item)}
{#each tags as tag}
<Tag
label={tag.label}
color={tag.color}
textColor={tag.textColor}
bgColor={tag.bgColor} />
{/each}
{/if}
</div>
{#if getDescription}
<span class="block text-xs text-gray-400 truncate">{getDescription(item)}</span>
{/if}
</div>
<!-- Tag -->
{#if getTags}
{@const tags = getTags(item)}
{#each tags as tag}
<Tag
label={tag.label}
textColor={tag.textColor}
bgColor={tag.bgColor} />
{/each}
{/if}
</div>
@@ -20,6 +20,7 @@
export let getOptionLabel: (item: any) => string | null = (item) => null
export let getOptionDescription: (item: any) => string | null = (item) => null
export let getOptionIsDisabled: (item: any) => boolean = (item) => false
export let getOptionCategory: (item: any) => string | null = (item) => null;
export let showDescriptionUnderDropdown: boolean = false;
@@ -28,6 +29,32 @@
$: selectedItem = options.find(i => getOptionId(i) === selectedValue);
// Group options by category when getOptionCategory is provided
$: groupedOptions = (() => {
if (!getOptionCategory) {
return [{ category: null, options: options }];
}
const groups = new Map<string | null, any[]>();
options.forEach(option => {
const category = getOptionCategory(option);
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(option);
});
// Sort categories alphabetically, with null category first
const sortedEntries = Array.from(groups.entries()).sort(([a], [b]) => {
if (a === null) return -1;
if (b === null) return 1;
return a.localeCompare(b);
});
return sortedEntries.map(([category, options]) => ({ category, options }));
})();
function handleSelect(value: string) {
try {
const item = options.find(i => getOptionId(i) === value);
@@ -115,28 +142,41 @@
<!-- Dropdown Menu -->
{#if isOpen && !disabled}
<div class="absolute z-50 w-full bg-gray-700 border border-gray-600 rounded-md shadow-lg max-h-96 overflow-y-auto mt-1">
{#each options as option}
<button
type="button"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
if (!getOptionIsDisabled(option)) {
handleSelect(getOptionId(option));
}
}}
class="w-full px-3 py-3 text-left transition-colors border-b border-gray-600 last:border-b-0
{getOptionIsDisabled(option) ? 'cursor-not-allowed opacity-50' : 'hover:bg-gray-600'}"
disabled={getOptionIsDisabled(option)}
>
<ListSelectItem
item={option}
getIcon={getOptionIcon}
getIconColor={getOptionIconColor}
getTags={getOptionTags}
getLabel={getOptionLabel}
getDescription={getOptionDescription} />
</button>
{#each groupedOptions as group, groupIndex}
<!-- Category Header -->
{#if group.category !== null}
<div class="px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wide bg-gray-800 border-b border-gray-600 sticky top-0">
{group.category}
</div>
{/if}
<!-- Options in this category -->
{#each group.options as option, optionIndex}
{@const isLastInGroup = optionIndex === group.options.length - 1}
{@const isLastGroup = groupIndex === groupedOptions.length - 1}
<button
type="button"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
if (!getOptionIsDisabled(option)) {
handleSelect(getOptionId(option));
}
}}
class="w-full px-3 py-3 text-left transition-colors
{!isLastInGroup || !isLastGroup ? 'border-b border-gray-600' : ''}
{getOptionIsDisabled(option) ? 'cursor-not-allowed opacity-50' : 'hover:bg-gray-600'}"
disabled={getOptionIsDisabled(option)}
>
<ListSelectItem
item={option}
getIcon={getOptionIcon}
getIconColor={getOptionIconColor}
getTags={getOptionTags}
getLabel={getOptionLabel}
getDescription={getOptionDescription} />
</button>
{/each}
{/each}
</div>
{/if}
+7 -12
View File
@@ -14,13 +14,8 @@ export interface TypeMetadata {
}
export interface TypeRegistry {
test_types: TypeMetadata[];
node_types: TypeMetadata[];
capabilities: TypeMetadata[];
criticality_levels: TypeMetadata[];
node_statuses: TypeMetadata[];
services: TypeMetadata[];
node_targets: TypeMetadata[];
diagnostic_statuses: TypeMetadata[];
}
export const registry = writable<TypeRegistry>();
@@ -53,6 +48,11 @@ function createRegistryHelpers<T extends keyof TypeRegistry>(category: T) {
const $registry = get(registry);
return $registry?.[category]?.find(item => item.id === id)?.icon || 'help-circle';
},
getCategory: (id: string | null) => {
const $registry = get(registry);
return $registry?.[category]?.find(item => item.id === id)?.category || "";
},
getColor: (id: string | null): ColorStyle => {
const $registry = get(registry);
@@ -86,13 +86,8 @@ function createRegistryHelpers<T extends keyof TypeRegistry>(category: T) {
}
// Create all the helpers
export const testTypes = createRegistryHelpers('test_types');
export const nodeTypes = createRegistryHelpers('node_types');
export const capabilities = createRegistryHelpers('capabilities');
export const criticalityLevels = createRegistryHelpers('criticality_levels');
export const nodeStatuses = createRegistryHelpers('node_statuses');
export const services = createRegistryHelpers('services');
export const nodeTargets = createRegistryHelpers('node_targets');
export const diagnosticStatuses = createRegistryHelpers('diagnostic_statuses');
export async function getRegistry() {
const result = await api.request<TypeRegistry>(