mirror of
https://github.com/mayanayza/netvisor.git
synced 2025-12-10 08:24:08 -06:00
services frontend getting there
This commit is contained in:
@@ -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>
|
||||
|
||||
+317
-278
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(', ');
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user