ui tweaks

This commit is contained in:
Maya
2025-08-14 01:01:01 -04:00
parent c16f9f5a54
commit fef9d30cda
19 changed files with 1291 additions and 752 deletions

View File

@@ -0,0 +1,151 @@
<!-- src/lib/components/nodes/NodeCard.svelte -->
<script lang="ts">
import { Edit, Settings, Trash2, Server, SquareActivity, CircleAlert, TriangleAlert, OctagonAlert } from 'lucide-svelte';
import type { Node, AssignedTest } from "$lib/types/nodes";
import { getNodeStatusDisplayName, getNodeStatusColor, getNodeTypeDisplayName } from "$lib/types/nodes";
import { getTestTypeDisplayName } from "$lib/types/tests";
import GenericCard from '../common/Card.svelte';
import type { CardListItem } from '$lib/types';
export let node: Node;
export let groupNames: string[] = [];
export let onEdit: (node: Node) => void = () => {};
export let onDelete: (node: Node) => void = () => {};
export let onAssignTest: (node: Node) => void = () => {};
export let onEditTest: (node: Node, test: AssignedTest) => void = () => {};
// export let onDeleteTest: (node: Node, test: AssignedTest) => void = () => {}; // Removed as it's not used in this implementation
// Get the display status - monitoring status takes precedence if disabled
function getDisplayStatus() {
if (!node.monitoring_enabled) {
return 'Monitoring Disabled';
}
return getNodeStatusDisplayName(node.current_status);
}
// Get the status color - gray for monitoring disabled, otherwise node status color
function getDisplayStatusColor() {
if (!node.monitoring_enabled) {
return 'text-gray-400';
}
return getNodeStatusColor(node.current_status);
}
// Build connection info
$: connectionInfo = (() => {
if (node.ip) {
return `IP: ${node.ip}${node.port ? `:${node.port}` : ''}`;
} else if (node.domain) {
return `Domain: ${node.domain}${node.port ? `:${node.port}` : ''}`;
}
return '';
})();
// Build card data
$: cardData = {
title: node.name,
subtitle: node.node_type ? getNodeTypeDisplayName(node.node_type) : 'Unknown Device',
status: getDisplayStatus(),
statusColor: getDisplayStatusColor(),
icon: Server,
iconColor: 'text-blue-400',
sections: connectionInfo ? [{
label: 'Connection',
value: connectionInfo
}] : [],
lists: [
{
label: 'Capabilities',
items: node.capabilities.map(cap => ({
id: cap,
label: cap,
bgColor: 'bg-blue-900/30',
color: 'text-blue-300'
})),
emptyText: 'No capabilities assigned'
},
{
label: 'Groups',
items: groupNames.map((name, i) => ({
id: node.node_groups[i] || name,
label: name,
bgColor: 'bg-green-900/30',
color: 'text-green-300'
})),
emptyText: 'No groups assigned'
},
{
label: 'Tests',
items: node.assigned_tests.map(test => ({
id: test.test_type,
label: getTestTypeDisplayName(test.test_type),
metadata: test,
disabled: !test.enabled
})),
emptyText: 'No tests assigned',
renderItem: (item: CardListItem) => {
const test = item.metadata as AssignedTest;
const icon = test.criticality === 'Critical' ? 'text-red-400' :
test.criticality === 'Important' ? 'text-yellow-300' : 'text-blue-300';
const iconComponent = test.criticality === 'Critical' ? 'OctagonAlert' :
test.criticality === 'Important' ? 'TriangleAlert' : 'CircleAlert';
return `
<div class="flex items-center space-x-2">
<svg class="w-4 h-4 ${icon}" fill="currentColor" viewBox="0 0 24 24">
${test.criticality === 'Critical' ?
'<path d="M12 2L2 22h20L12 2zm0 4l7.5 13h-15L12 6z"/>' :
test.criticality === 'Important' ?
'<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>' :
'<circle cx="12" cy="12" r="10"/>'
}
</svg>
<span class="text-gray-300">${item.label}</span>
${item.disabled ? '<span class="text-xs text-gray-500">(disabled)</span>' : ''}
${test.monitor_interval_minutes ? `<span class="text-xs text-gray-500">${test.monitor_interval_minutes}m</span>` : ''}
</div>
`;
},
itemActions: (item: CardListItem) => [{
label: 'Edit Test',
icon: Settings,
color: 'text-gray-400',
hoverColor: 'text-white',
bgHover: 'hover:bg-gray-700',
onClick: () => onEditTest(node, item.metadata as AssignedTest)
}]
}
],
actions: [
{
label: 'Delete Node',
icon: Trash2,
color: 'text-gray-400',
hoverColor: 'text-red-400',
bgHover: 'hover:bg-red-900/20',
onClick: () => onDelete(node)
},
{
label: 'Assign Test',
icon: SquareActivity,
color: 'text-gray-400',
hoverColor: 'text-white',
bgHover: 'hover:bg-gray-700',
onClick: () => onAssignTest(node)
},
{
label: 'Edit Node',
icon: Settings,
color: 'text-gray-400',
hoverColor: 'text-white',
bgHover: 'hover:bg-gray-700',
onClick: () => onEdit(node)
}
]
};
</script>
<GenericCard {...cardData} />

View File

@@ -0,0 +1,103 @@
<!-- src/lib/components/node-groups/NodeGroupCard.svelte -->
<script lang="ts">
import { Edit, Settings, Trash2, Users, Play } from 'lucide-svelte';
import type { NodeGroup } from "$lib/types/node-groups";
import type { Node } from "$lib/types/nodes";
import GenericCard from '../common/Card.svelte';
import type { CardListItem } from '$lib/types';
export let group: NodeGroup;
export let nodes: Node[] = [];
export let onEdit: (group: NodeGroup) => void = () => {};
export let onDelete: (group: NodeGroup) => void = () => {};
export let onExecute: (group: NodeGroup) => void = () => {};
// Get node name from ID
function getNodeName(nodeId: string): string {
const node = nodes.find(n => n.id === nodeId);
return node ? node.name : `Node ${nodeId.slice(0, 8)}...`;
}
// Get status info
function getStatusInfo() {
if (group.auto_diagnostic_enabled) {
return {
status: 'Auto-Diagnostic Enabled',
color: 'text-green-400'
};
} else {
return {
status: 'Manual Only',
color: 'text-gray-400'
};
}
}
$: statusInfo = getStatusInfo();
// Build card data
$: cardData = {
title: group.name,
subtitle: `${group.node_sequence.length} nodes in sequence`,
status: statusInfo.status,
statusColor: statusInfo.color,
icon: Users,
iconColor: 'text-purple-400',
sections: group.description ? [{
label: 'Description',
value: group.description
}] : [],
lists: [
{
label: 'Diagnostic Sequence',
items: group.node_sequence.map((nodeId, index) => ({
id: nodeId,
label: `${index + 1}. ${getNodeName(nodeId)}`,
bgColor: 'bg-purple-900/30',
color: 'text-purple-300'
})),
emptyText: 'No nodes in sequence',
renderItem: (item: CardListItem) => {
return `
<div class="flex items-center space-x-2">
<span class="text-gray-400 font-mono text-sm min-w-[2rem]">${item.label.split('.')[0]}.</span>
<span class="text-purple-300">${item.label.split('. ')[1]}</span>
</div>
`;
}
}
],
actions: [
{
label: 'Delete Group',
icon: Trash2,
color: 'text-gray-400',
hoverColor: 'text-red-400',
bgHover: 'hover:bg-red-900/20',
onClick: () => onDelete(group)
},
{
label: 'Execute Diagnostic',
icon: Play,
color: 'text-gray-400',
hoverColor: 'text-green-400',
bgHover: 'hover:bg-green-900/20',
onClick: () => onExecute(group),
disabled: group.node_sequence.length === 0
},
{
label: 'Edit Group',
icon: Settings,
color: 'text-gray-400',
hoverColor: 'text-white',
bgHover: 'hover:bg-gray-700',
onClick: () => onEdit(group)
}
]
};
</script>
<GenericCard {...cardData} />

View File

@@ -0,0 +1,118 @@
<!-- src/lib/components/common/GenericCard.svelte -->
<script lang="ts">
import { Edit, Settings, Trash2 } from 'lucide-svelte';
import type { CardAction, CardSection, CardList } from '$lib/types';
export let title: string;
export let subtitle: string = '';
export let status: string = '';
export let statusColor: string = 'text-gray-400';
export let icon: any = null;
export let iconColor: string = 'text-blue-400';
export let actions: CardAction[] = [];
export let sections: CardSection[] = [];
export let lists: CardList[] = [];
</script>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-gray-600 transition-colors flex flex-col h-full">
<!-- Header -->
<div class="flex justify-between items-start mb-4">
<div class="flex items-center space-x-3">
{#if icon}
<svelte:component this={icon} size={24} class={iconColor} />
{/if}
<div>
<h3 class="text-lg font-semibold text-white">{title}</h3>
{#if subtitle}
<p class="text-sm text-gray-400">{subtitle}</p>
{/if}
</div>
</div>
{#if status}
<span class="text-sm font-medium {statusColor}">{status}</span>
{/if}
</div>
<!-- Content - grows to fill available space -->
<div class="flex-grow space-y-3">
<!-- Basic info sections -->
{#each sections as section}
<div class="text-sm text-gray-300">
<span class="text-gray-400">{section.label}:</span>
<span class="ml-2">{section.value}</span>
</div>
{/each}
<!-- List sections -->
{#each lists as list}
<div class="text-sm">
<span class="text-gray-400">{list.label}:</span>
{#if list.items.length > 0}
<div class="mt-1 space-y-1">
{#each list.items as item}
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
{#if list.renderItem}
{@html list.renderItem(item)}
{:else}
<span class="inline-block {item.bgColor || 'bg-blue-900/30'} {item.color || 'text-blue-300'} px-2 py-1 rounded text-xs">
{item.label}
{#if item.disabled}
<span class="text-xs text-gray-500">(disabled)</span>
{/if}
</span>
{/if}
</div>
{#if list.itemActions}
<div class="flex items-center space-x-1">
{#each list.itemActions(item) as action}
<button
on:click={action.onClick}
disabled={action.disabled}
class="p-1 {action.color || 'text-gray-400'} hover:{action.hoverColor || 'text-white'} {action.bgHover || 'hover:bg-gray-700'} rounded transition-colors disabled:opacity-50"
title={action.label}
>
<svelte:component this={action.icon} size={16} />
</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<span class="text-gray-500 ml-2">{list.emptyText || `No ${list.label.toLowerCase()}`}</span>
{/if}
</div>
{/each}
</div>
<!-- Action Buttons -->
{#if actions.length > 0}
<div class="flex justify-between items-center pt-4 mt-4 border-t border-gray-700">
{#each actions as action}
<button
on:click={action.onClick}
disabled={action.disabled}
class="p-2 {action.color || 'text-gray-400'} hover:{action.hoverColor || 'text-white'} {action.bgHover || 'hover:bg-gray-700'} rounded transition-colors disabled:opacity-50"
title={action.label}
>
<svelte:component this={action.icon} size={16} />
</button>
{/each}
</div>
{/if}
</div>
<style>
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:disabled:hover {
background-color: transparent;
color: inherit;
}
</style>

View File

@@ -0,0 +1,87 @@
<!-- src/lib/components/common/GenericEditModal.svelte -->
<script lang="ts">
import { X } from 'lucide-svelte';
export let isOpen = false;
export let title: string;
export let loading = false;
export let onSubmit: (data: any) => Promise<void> | void;
export let onClose: () => void;
export let submitLabel: string = 'Save';
export let cancelLabel: string = 'Cancel';
export let deleteLabel: string = 'Delete';
export let onDelete: (() => Promise<void> | void) | null = null;
export let deleting = false;
let formElement: HTMLFormElement;
async function handleSubmit(event: Event) {
event.preventDefault();
const formData = new FormData(formElement);
const data = Object.fromEntries(formData.entries());
await onSubmit(data);
}
async function handleDelete() {
if (onDelete) {
await onDelete();
}
}
</script>
{#if isOpen}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-white">{title}</h2>
<button
on:click={onClose}
class="text-gray-400 hover:text-white"
>
<X size={24} />
</button>
</div>
<form bind:this={formElement} on:submit={handleSubmit} class="space-y-6">
<!-- Content slot -->
<slot />
<!-- Action Buttons -->
<div class="flex justify-between pt-4">
<!-- Delete button (if provided) -->
<div>
{#if onDelete}
<button
type="button"
on:click={handleDelete}
disabled={deleting || loading}
class="flex items-center gap-2 px-4 py-2 text-red-300 hover:text-red-200 border border-red-600 rounded-md hover:border-red-500 transition-colors disabled:opacity-50"
>
{deleting ? 'Deleting...' : deleteLabel}
</button>
{/if}
</div>
<!-- Save/Cancel buttons -->
<div class="flex space-x-3">
<button
type="button"
on:click={onClose}
disabled={loading || deleting}
class="px-4 py-2 text-gray-300 hover:text-white border border-gray-600 rounded-md hover:border-gray-500 transition-colors disabled:opacity-50"
>
{cancelLabel}
</button>
<button
type="submit"
disabled={loading || deleting}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Saving...' : submitLabel}
</button>
</div>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { ArrowUp, ArrowDown, Trash2, Plus } from 'lucide-svelte';
export let label: string;
export let items: string[] = [];
export let availableOptions: ListOption[] = [];
export let placeholder: string = 'Select an item to add';
export let required: boolean = false;
export let allowReorder: boolean = true;
export let getDisplayName: (id: string) => string = (id) => id;
export let error: string = '';
interface ListOption {
id: string;
label: string;
subtitle?: string;
}
let selectedItemId = '';
$: filteredOptions = availableOptions.filter(option => !items.includes(option.id));
function addItem() {
if (selectedItemId && !items.includes(selectedItemId)) {
items = [...items, selectedItemId];
selectedItemId = '';
}
}
function removeItem(itemId: string) {
items = items.filter(id => id !== itemId);
}
function moveItemUp(index: number) {
if (index > 0 && allowReorder) {
const newItems = [...items];
[newItems[index - 1], newItems[index]] = [newItems[index], newItems[index - 1]];
items = newItems;
}
}
function moveItemDown(index: number) {
if (index < items.length - 1 && allowReorder) {
const newItems = [...items];
[newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]];
items = newItems;
}
}
</script>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
{label}
{#if required}<span class="text-red-400">*</span>{/if}
</label>
<!-- Add Item -->
<div class="flex gap-2 mb-3">
<select
bind:value={selectedItemId}
class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">{placeholder}</option>
{#each filteredOptions as option}
<option value={option.id}>
{option.label}
{#if option.subtitle}({option.subtitle}){/if}
</option>
{/each}
</select>
<button
type="button"
on:click={addItem}
disabled={!selectedItemId}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Plus size={16} />
Add
</button>
</div>
<!-- Current Items -->
{#if items.length > 0}
<div class="space-y-2 mb-3">
{#each items as itemId, index}
<div class="flex items-center gap-2 bg-gray-700/50 rounded-lg p-3">
{#if allowReorder}
<span class="text-gray-400 font-mono text-sm min-w-[2rem]">{index + 1}.</span>
{/if}
<span class="flex-1 text-white">{getDisplayName(itemId)}</span>
<div class="flex items-center gap-1">
{#if allowReorder}
<button
type="button"
on:click={() => moveItemUp(index)}
disabled={index === 0}
class="p-1 text-gray-400 hover:text-white hover:bg-gray-600 rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Move up"
>
<ArrowUp size={16} />
</button>
<button
type="button"
on:click={() => moveItemDown(index)}
disabled={index === items.length - 1}
class="p-1 text-gray-400 hover:text-white hover:bg-gray-600 rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Move down"
>
<ArrowDown size={16} />
</button>
{/if}
<button
type="button"
on:click={() => removeItem(itemId)}
class="p-1 text-gray-400 hover:text-red-400 hover:bg-red-900/20 rounded"
title="Remove"
>
<Trash2 size={16} />
</button>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-gray-500 text-sm mb-3 p-3 bg-gray-700/30 rounded-lg text-center">
No {label.toLowerCase()} added yet
</div>
{/if}
<!-- Error message -->
{#if error}
<p class="text-red-400 text-xs mt-1">{error}</p>
{/if}
</div>

View File

@@ -1,14 +1,18 @@
<!-- src/lib/components/modals/NodeEditor.svelte -->
<script lang="ts">
import { X } from 'lucide-svelte';
import type { Node, NodeType, NodeCapability } from '../../types/nodes';
import { getNodeTypeDisplayName } from '../../types/nodes';
import type { Node, NodeType, NodeCapability } from "$lib/types/nodes";
import type { TestType } from "$lib/types/tests";
import { getNodeTypeDisplayName } from "$lib/types/nodes";
import { getTestTypeDisplayName } from "$lib/types/tests";
import GenericEditModal from '../common/EditModal.svelte';
import ListManager from '../common/ListManager.svelte';
export let node: Node | null = null;
export let isOpen = false;
export let onCreate: (data: any) => void = () => {};
export let onUpdate: (id: string, data: any) => void = () => {};
export let onClose: () => void = () => {};
export let onCreate: (data: any) => Promise<void> | void;
export let onUpdate: (id: string, data: any) => Promise<void> | void;
export let onClose: () => void;
export let onDelete: ((id: string) => Promise<void> | void) | null = null;
let formData = {
name: '',
@@ -17,36 +21,46 @@
port: '',
path: '',
description: '',
node_type: undefined as NodeType | undefined,
node_type: '' as NodeType | '',
capabilities: [] as NodeCapability[],
monitoring_enabled: false
monitoring_enabled: false,
assigned_tests: [] as string[]
};
let loading = false;
let deleting = false;
let errors: Record<string, string> = {};
// Available options
$: isEditing = node !== null;
$: title = isEditing ? `Edit ${node?.name}` : 'Create Node';
// Available options for dropdowns
const nodeTypes: NodeType[] = [
'Router', 'Switch', 'AccessPoint', 'Firewall',
'WebServer', 'DatabaseServer', 'MediaServer', 'DnsServer', 'VpnServer', 'NasDevice',
'Workstation', 'IotDevice', 'Printer', 'Camera',
'UnknownDevice'
'Workstation', 'IotDevice', 'Printer', 'Camera', 'UnknownDevice'
];
const nodeCapabilities: NodeCapability[] = [
'SshAccess', 'RdpAccess', 'VncAccess',
'HttpService', 'HttpsService',
'DatabaseService',
'HttpService', 'HttpsService', 'DatabaseService',
'DnsService', 'EmailService', 'FtpService'
];
// Reset form when node changes or modal opens
const testTypes: TestType[] = [
'Connectivity', 'DirectIp', 'Ping', 'WellknownIp',
'DnsResolution', 'DnsOverHttps',
'VpnConnectivity', 'VpnTunnel',
'ServiceHealth'
];
// Initialize form data when node changes or modal opens
$: if (isOpen) {
resetForm();
}
function resetForm() {
if (node) {
// Editing existing node
formData = {
name: node.name,
domain: node.domain || '',
@@ -54,12 +68,12 @@
port: node.port?.toString() || '',
path: node.path || '',
description: node.description || '',
node_type: node.node_type,
capabilities: [...(node.capabilities || [])],
monitoring_enabled: node.monitoring_enabled
node_type: node.node_type || '',
capabilities: [...node.capabilities],
monitoring_enabled: node.monitoring_enabled,
assigned_tests: node.assigned_tests.map(t => t.test_type)
};
} else {
// Creating new node
formData = {
name: '',
domain: '',
@@ -67,250 +81,254 @@
port: '',
path: '',
description: '',
node_type: undefined,
node_type: '',
capabilities: [],
monitoring_enabled: false
monitoring_enabled: false,
assigned_tests: []
};
}
errors = {};
}
function handleCapabilityToggle(capability: NodeCapability) {
if (formData.capabilities.includes(capability)) {
formData.capabilities = formData.capabilities.filter(c => c !== capability);
} else {
formData.capabilities = [...formData.capabilities, capability];
}
}
function handleNodeTypeChange() {
// Auto-suggest capabilities based on node type
if (formData.node_type) {
// This would use the suggested_capabilities() method from the backend
// For now, just keep existing capabilities
}
}
async function handleSubmit() {
loading = true;
function validateForm(): boolean {
errors = {};
if (!formData.name.trim()) {
errors.name = 'Name is required';
}
if (formData.port && (isNaN(Number(formData.port)) || Number(formData.port) < 1 || Number(formData.port) > 65535)) {
errors.port = 'Port must be between 1 and 65535';
}
return Object.keys(errors).length === 0;
}
async function handleSubmit(data: any) {
// Convert form data to proper types
const nodeData = {
name: formData.name.trim(),
domain: formData.domain.trim() || undefined,
ip: formData.ip.trim() || undefined,
port: formData.port ? Number(formData.port) : undefined,
path: formData.path.trim() || undefined,
description: formData.description.trim() || undefined,
node_type: formData.node_type || undefined,
capabilities: formData.capabilities,
monitoring_enabled: formData.monitoring_enabled,
// Note: assigned_tests would need more complex handling for full test configs
// This is simplified for the generic approach
};
if (!validateForm()) {
return;
}
loading = true;
try {
const submitData = {
name: formData.name,
domain: formData.domain || undefined,
ip: formData.ip || undefined,
port: formData.port ? parseInt(formData.port) : undefined,
path: formData.path || undefined,
description: formData.description || undefined,
node_type: formData.node_type,
capabilities: formData.capabilities,
monitoring_enabled: formData.monitoring_enabled
};
if (node) {
// Update existing node
onUpdate(node.id, submitData);
if (isEditing && node) {
await onUpdate(node.id, nodeData);
} else {
// Create new node
onCreate(submitData);
await onCreate(nodeData);
}
} catch (error) {
console.error('Error saving node:', error);
} finally {
loading = false;
}
}
function handleClose() {
resetForm();
onClose();
async function handleDelete() {
if (onDelete && node) {
deleting = true;
try {
await onDelete(node.id);
} finally {
deleting = false;
}
}
}
function getCapabilityDisplayName(capability: NodeCapability): string {
return capability; // Could be enhanced with a mapping function
}
function getTestDisplayName(testType: string): string {
return getTestTypeDisplayName(testType as TestType);
}
</script>
{#if isOpen}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-white">
{node ? 'Edit Node' : 'Create Node'}
</h2>
<button
on:click={handleClose}
class="text-gray-400 hover:text-white"
>
<X size={24} />
</button>
</div>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<!-- Basic Information -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
Name *
</label>
<input
id="name"
bind:value={formData.name}
type="text"
required
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter node name"
/>
</div>
<!-- Node Type -->
<div>
<label for="node_type" class="block text-sm font-medium text-gray-300 mb-1">
Node Type
</label>
<select
id="node_type"
bind:value={formData.node_type}
on:change={handleNodeTypeChange}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={undefined}>Select type...</option>
{#each nodeTypes as nodeType}
<option value={nodeType}>{getNodeTypeDisplayName(nodeType)}</option>
{/each}
</select>
</div>
</div>
<!-- Network Information -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- IP Address -->
<div>
<label for="ip" class="block text-sm font-medium text-gray-300 mb-1">
IP Address
</label>
<input
id="ip"
bind:value={formData.ip}
type="text"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="192.168.1.100"
/>
</div>
<!-- Domain -->
<div>
<label for="domain" class="block text-sm font-medium text-gray-300 mb-1">
Domain
</label>
<input
id="domain"
bind:value={formData.domain}
type="text"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="example.com"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Port -->
<div>
<label for="port" class="block text-sm font-medium text-gray-300 mb-1">
Port
</label>
<input
id="port"
bind:value={formData.port}
type="number"
min="1"
max="65535"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="80"
/>
</div>
<!-- Path -->
<div>
<label for="path" class="block text-sm font-medium text-gray-300 mb-1">
Path
</label>
<input
id="path"
bind:value={formData.path}
type="text"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="/api"
/>
</div>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-300 mb-1">
Description
</label>
<textarea
id="description"
bind:value={formData.description}
rows="3"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Optional description"
></textarea>
</div>
<!-- Capabilities -->
<div>
<label for="capabilities" class="block text-sm font-medium text-gray-300 mb-2">
Capabilities
</label>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
{#each nodeCapabilities as capability}
<label class="flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={formData.capabilities.includes(capability)}
on:change={() => handleCapabilityToggle(capability)}
class="rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500"
/>
<span class="text-gray-300">{capability}</span>
</label>
{/each}
</div>
</div>
<!-- Monitoring -->
<div>
<label class="flex items-center space-x-2">
<input
type="checkbox"
bind:checked={formData.monitoring_enabled}
class="rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-gray-300">Enable monitoring</span>
</label>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
on:click={handleClose}
class="px-4 py-2 text-gray-300 hover:text-white border border-gray-600 rounded-md hover:border-gray-500 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !formData.name}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Saving...' : (node ? 'Update Node' : 'Create Node')}
</button>
</div>
</form>
<GenericEditModal
{isOpen}
{title}
{loading}
{deleting}
onSubmit={handleSubmit}
{onClose}
onDelete={isEditing ? handleDelete : null}
submitLabel={isEditing ? 'Update Node' : 'Create Node'}
>
<!-- Basic Information -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
Name *
</label>
<input
id="name"
name="name"
bind:value={formData.name}
type="text"
required
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
class:border-red-500={errors.name}
/>
{#if errors.name}
<p class="text-red-400 text-xs mt-1">{errors.name}</p>
{/if}
</div>
<div>
<label for="node_type" class="block text-sm font-medium text-gray-300 mb-1">
Node Type
</label>
<select
id="node_type"
name="node_type"
bind:value={formData.node_type}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select node type</option>
{#each nodeTypes as type}
<option value={type}>{getNodeTypeDisplayName(type)}</option>
{/each}
</select>
</div>
</div>
{/if}
<style>
input[type="checkbox"] {
accent-color: #3b82f6;
}
</style>
<!-- Connection Information -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="ip" class="block text-sm font-medium text-gray-300 mb-1">
IP Address
</label>
<input
id="ip"
name="ip"
bind:value={formData.ip}
type="text"
placeholder="192.168.1.100"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label for="domain" class="block text-sm font-medium text-gray-300 mb-1">
Domain/Hostname
</label>
<input
id="domain"
name="domain"
bind:value={formData.domain}
type="text"
placeholder="server.local"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label for="port" class="block text-sm font-medium text-gray-300 mb-1">
Port
</label>
<input
id="port"
name="port"
bind:value={formData.port}
type="number"
min="1"
max="65535"
placeholder="80"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
class:border-red-500={errors.port}
/>
{#if errors.port}
<p class="text-red-400 text-xs mt-1">{errors.port}</p>
{/if}
</div>
</div>
<!-- Path and Description -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="path" class="block text-sm font-medium text-gray-300 mb-1">
Path
</label>
<input
id="path"
name="path"
bind:value={formData.path}
type="text"
placeholder="/api"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="flex items-center space-x-2 pt-7">
<input
type="checkbox"
name="monitoring_enabled"
bind:checked={formData.monitoring_enabled}
class="rounded bg-gray-700 border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm font-medium text-gray-300">Enable Monitoring</span>
</label>
</div>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-300 mb-1">
Description
</label>
<textarea
id="description"
name="description"
bind:value={formData.description}
rows="3"
placeholder="Optional description..."
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
</div>
<!-- Capabilities List Manager -->
<ListManager
label="Capabilities"
bind:items={formData.capabilities}
availableOptions={nodeCapabilities.map(cap => ({
id: cap,
label: getCapabilityDisplayName(cap)
}))}
placeholder="Select a capability to add"
allowReorder={false}
getDisplayName={(id: string) => getCapabilityDisplayName(id as NodeCapability)}
/>
<!-- Tests List Manager (simplified - in real implementation you'd want full test config) -->
<!-- <ListManager
label="Assigned Tests"
bind:items={formData.assigned_tests}
availableOptions={testTypes.map(test => ({
id: test,
label: getTestDisplayName(test)
}))}
placeholder="Select a test to assign"
allowReorder={false}
getDisplayName={getTestDisplayName}
/> -->
<!-- Note about test assignment -->
<div class="bg-blue-900/20 border border-blue-500/30 rounded-lg p-3">
<p class="text-blue-300 text-sm">
<strong>Note:</strong> Test assignments are managed separately through the "Assign Test" button on the node card,
as they require detailed configuration beyond just the test type.
</p>
</div>
</GenericEditModal>

View File

@@ -1,13 +1,16 @@
<script lang="ts">
import { X, ArrowUp, ArrowDown, Trash2 } from 'lucide-svelte';
import type { NodeGroup } from "$lib/types/node-groups";
import type { Node } from "$lib/types/nodes";
import { nodes } from '../../stores/nodes';
import type { NodeGroup } from '../../stores/node-groups';
import GenericEditModal from '../common/EditModal.svelte';
import ListManager from '../common/ListManager.svelte';
export let group: NodeGroup | null = null;
export let isOpen = false;
export let onCreate: (data: any) => void = () => {};
export let onUpdate: (id: string, data: any) => void = () => {};
export let onClose: () => void = () => {};
export let onCreate: (data: any) => Promise<void> | void;
export let onUpdate: (id: string, data: any) => Promise<void> | void;
export let onClose: () => void;
export let onDelete: ((id: string) => Promise<void> | void) | null = null;
let formData = {
name: '',
@@ -16,13 +19,12 @@
auto_diagnostic_enabled: true
};
let selectedNodeId = '';
let loading = false;
let deleting = false;
let errors: Record<string, string> = {};
$: isEditing = group !== null;
$: title = isEditing ? `Edit ${group?.name}` : 'Create Node Group';
$: availableNodes = $nodes.filter(node => !formData.node_sequence.includes(node.id));
// Initialize form data when group changes or modal opens
$: if (isOpen) {
@@ -45,7 +47,6 @@
auto_diagnostic_enabled: true
};
}
selectedNodeId = '';
errors = {};
}
@@ -63,30 +64,38 @@
return Object.keys(errors).length === 0;
}
function addNode() {
if (selectedNodeId && !formData.node_sequence.includes(selectedNodeId)) {
formData.node_sequence = [...formData.node_sequence, selectedNodeId];
selectedNodeId = '';
async function handleSubmit(data: any) {
const groupData = {
name: formData.name.trim(),
description: formData.description.trim(),
node_sequence: formData.node_sequence,
auto_diagnostic_enabled: formData.auto_diagnostic_enabled
};
if (!validateForm()) {
return;
}
loading = true;
try {
if (isEditing && group) {
await onUpdate(group.id, groupData);
} else {
await onCreate(groupData);
}
} finally {
loading = false;
}
}
function removeNode(nodeId: string) {
formData.node_sequence = formData.node_sequence.filter(id => id !== nodeId);
}
function moveNodeUp(index: number) {
if (index > 0) {
const newSequence = [...formData.node_sequence];
[newSequence[index - 1], newSequence[index]] = [newSequence[index], newSequence[index - 1]];
formData.node_sequence = newSequence;
}
}
function moveNodeDown(index: number) {
if (index < formData.node_sequence.length - 1) {
const newSequence = [...formData.node_sequence];
[newSequence[index], newSequence[index + 1]] = [newSequence[index + 1], newSequence[index]];
formData.node_sequence = newSequence;
async function handleDelete() {
if (onDelete && group) {
deleting = true;
try {
await onDelete(group.id);
} finally {
deleting = false;
}
}
}
@@ -94,220 +103,89 @@
const node = $nodes.find(n => n.id === nodeId);
return node ? node.name : `Node ${nodeId.slice(0, 8)}...`;
}
async function handleSubmit() {
if (!validateForm()) return;
loading = true;
try {
const requestData = {
name: formData.name.trim(),
description: formData.description.trim() || '',
node_sequence: formData.node_sequence,
auto_diagnostic_enabled: formData.auto_diagnostic_enabled
};
if (isEditing && group) {
onUpdate(group.id, requestData);
} else {
onCreate(requestData);
}
} catch (error) {
console.error('Form submission error:', error);
} finally {
loading = false;
}
}
function handleClose() {
resetForm();
onClose();
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
handleClose();
}
}
</script>
{#if isOpen}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
on:click={handleBackdropClick}
on:keydown={(e) => e.key === 'Escape' && handleClose()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-700">
<h2 class="text-xl font-semibold text-white">{title}</h2>
<button
on:click={handleClose}
class="text-gray-400 hover:text-white transition-colors"
>
<X class="w-6 h-6" />
</button>
</div>
<!-- Form -->
<form on:submit|preventDefault={handleSubmit} class="p-6 space-y-4">
<!-- Basic Information -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
Group Name *
</label>
<input
id="name"
bind:value={formData.name}
type="text"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
class:border-red-500={errors.name}
placeholder="Enter group name"
required
/>
{#if errors.name}
<p class="text-red-400 text-xs mt-1">{errors.name}</p>
{/if}
</div>
<div class="flex items-center">
<input
id="auto_diagnostic_enabled"
bind:checked={formData.auto_diagnostic_enabled}
type="checkbox"
class="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
/>
<label for="auto_diagnostic_enabled" class="ml-2 text-sm text-gray-300">
Enable auto-diagnostic
</label>
</div>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-gray-300 mb-1">
Description
</label>
<textarea
id="description"
bind:value={formData.description}
rows="3"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Optional description for this group"
></textarea>
</div>
<!-- Node Sequence -->
<div>
<label for="sequence" class="block text-sm font-medium text-gray-300 mb-2">
Diagnostic Sequence *
</label>
<!-- Add Node -->
<div class="flex gap-2 mb-3">
<select
bind:value={selectedNodeId}
class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select a node to add</option>
{#each availableNodes as node}
<option value={node.id}>{node.name} ({node.ip || node.domain || 'No address'})</option>
{/each}
</select>
<button
type="button"
on:click={addNode}
disabled={!selectedNodeId}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add
</button>
</div>
<!-- Current Sequence -->
{#if formData.node_sequence.length > 0}
<div class="space-y-2 mb-3">
{#each formData.node_sequence as nodeId, index}
<div class="flex items-center gap-2 bg-gray-700/50 rounded-lg p-3">
<span class="text-gray-400 font-mono text-sm">{index + 1}.</span>
<span class="flex-1 text-white">{getNodeName(nodeId)}</span>
<div class="flex items-center gap-1">
<button
type="button"
on:click={() => moveNodeUp(index)}
disabled={index === 0}
class="p-1 text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
title="Move up"
>
<ArrowUp class="w-4 h-4" />
</button>
<button
type="button"
on:click={() => moveNodeDown(index)}
disabled={index === formData.node_sequence.length - 1}
class="p-1 text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
title="Move down"
>
<ArrowDown class="w-4 h-4" />
</button>
<button
type="button"
on:click={() => removeNode(nodeId)}
class="p-1 text-red-400 hover:text-red-300"
title="Remove node"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-center py-8 bg-gray-700/20 rounded-lg border-2 border-dashed border-gray-600">
<p class="text-gray-400">No nodes in sequence</p>
<p class="text-gray-500 text-sm mt-1">Add nodes above to define the diagnostic order</p>
</div>
{/if}
{#if errors.nodes}
<p class="text-red-400 text-xs mt-1">{errors.nodes}</p>
{/if}
<p class="text-xs text-gray-400 mt-2">
Diagnostics will run tests on nodes in this order. Drag to reorder or use the arrow buttons.
</p>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-gray-700">
<button
type="button"
on:click={handleClose}
class="px-4 py-2 text-gray-300 border border-gray-600 rounded-md hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{#if loading}
Saving...
{:else}
{isEditing ? 'Update' : 'Create'} Group
{/if}
</button>
</div>
</form>
</div>
<GenericEditModal
{isOpen}
{title}
{loading}
{deleting}
onSubmit={handleSubmit}
{onClose}
onDelete={isEditing ? handleDelete : null}
submitLabel={isEditing ? 'Update Group' : 'Create Group'}
>
<!-- Basic Information -->
<div>
<label for="name" class="block text-sm font-medium text-gray-300 mb-1">
Group Name *
</label>
<input
id="name"
name="name"
bind:value={formData.name}
type="text"
required
placeholder="VPN Access Path"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
class:border-red-500={errors.name}
/>
{#if errors.name}
<p class="text-red-400 text-xs mt-1">{errors.name}</p>
{/if}
</div>
{/if}
<div>
<label for="description" class="block text-sm font-medium text-gray-300 mb-1">
Description
</label>
<textarea
id="description"
name="description"
bind:value={formData.description}
rows="3"
placeholder="Describe the purpose of this diagnostic sequence..."
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
</div>
<!-- Auto Diagnostic Toggle -->
<div>
<label class="flex items-center space-x-2">
<input
type="checkbox"
name="auto_diagnostic_enabled"
bind:checked={formData.auto_diagnostic_enabled}
class="rounded bg-gray-700 border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm font-medium text-gray-300">Enable Auto-Diagnostic</span>
</label>
<p class="text-xs text-gray-400 mt-1">
When enabled, this diagnostic will run automatically when any node in the group fails a test
</p>
</div>
<!-- Node Sequence Manager -->
<ListManager
label="Diagnostic Sequence"
bind:items={formData.node_sequence}
availableOptions={$nodes.map(node => ({
id: node.id,
label: node.name,
subtitle: node.ip || node.domain || 'No address'
}))}
placeholder="Select a node to add"
required={true}
allowReorder={true}
getDisplayName={getNodeName}
error={errors.nodes}
/>
<!-- Info about diagnostic sequence -->
<div class="bg-purple-900/20 border border-purple-500/30 rounded-lg p-3">
<p class="text-purple-300 text-sm">
<strong>Diagnostic Sequence:</strong> Tests will be executed on nodes in the order specified above.
This allows you to follow logical network paths and dependencies during troubleshooting.
</p>
</div>
</GenericEditModal>

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { X, AlertTriangle } from 'lucide-svelte';
import type { Node } from "$lib/types/nodes";
import { X, AlertTriangle, Trash2 } from 'lucide-svelte';
import type { Node, AssignedTest } from "$lib/types/nodes";
import type { TestType } from "$lib/types/tests";
import type { TestCriticality } from "$lib/types/nodes";
import { getTestTypeDisplayName } from "$lib/types/tests";
import { getTestTypeDisplayName } from "$lib/types/tests";
import { getTestDescription } from "$lib/types/tests";
export let node: Node | null = null;
export let test: AssignedTest | null = null;
export let isOpen = false;
export let onAssigned: (node: Node, warning?: string) => void = () => {};
export let onClose: () => void = () => {};
@@ -14,6 +15,7 @@
let selectedTestType: TestType = 'Connectivity';
let selectedCriticality: TestCriticality = 'Important';
let monitorInterval = '5';
let enabled = true;
let testConfig = {
port: '',
domain: '',
@@ -25,6 +27,7 @@
expected_result: 'Success'
};
let loading = false;
let deleting = false;
let compatibilityInfo: any = null;
let loadingCompatibility = false;
@@ -38,15 +41,14 @@
const criticalityLevels: TestCriticality[] = ['Critical', 'Important', 'Informational'];
$: existingTests = node?.assigned_tests.map(t => t.test_type) || [];
$: isTestAlreadyAssigned = existingTests.includes(selectedTestType);
$: isTestAlreadyAssigned = !test && existingTests.includes(selectedTestType);
$: nodeTarget = node?.ip || node?.domain || node?.name || '';
$: isEditMode = test !== null;
// Load compatibility info when node changes and reset form with node's values
$: if (node && isOpen) {
loadCompatibilityInfo();
// Reset form with node's values when node changes
testConfig.port = node.port?.toString() || '';
testConfig.target = nodeTarget;
resetForm();
}
async function loadCompatibilityInfo() {
@@ -66,19 +68,94 @@
}
function resetForm() {
selectedTestType = 'Connectivity';
selectedCriticality = 'Important';
monitorInterval = '5';
testConfig = {
port: node?.port?.toString() || '',
domain: '',
timeout: '30000',
path: '/',
target: nodeTarget,
expected_subnet: '10.100.0.0/24',
attempts: '4',
expected_result: 'Success'
};
if (test && node) {
// Edit mode - populate with existing test data
selectedTestType = test.test_type;
selectedCriticality = test.criticality;
monitorInterval = test.monitor_interval_minutes?.toString() || '';
enabled = test.enabled;
// Extract config fields from the flattened structure
const config = extractConfigFromTest(test);
testConfig = {
...testConfig,
...config
};
} else {
// Create mode - reset to defaults
selectedTestType = 'Connectivity';
selectedCriticality = 'Important';
monitorInterval = '5';
enabled = true;
testConfig = {
port: node?.port?.toString() || '',
domain: '',
timeout: '30000',
path: '/',
target: nodeTarget,
expected_subnet: '10.100.0.0/24',
attempts: '4',
expected_result: 'Success'
};
}
}
function extractConfigFromTest(test: AssignedTest): any {
const config = test.test_config;
// Extract fields from the nested test configuration
if ('Connectivity' in config) {
return {
target: config.Connectivity.target,
port: config.Connectivity.port?.toString() || '',
timeout: config.Connectivity.timeout?.toString() || '30000',
expected_result: config.Connectivity.expected_result
};
} else if ('DirectIp' in config) {
return {
target: config.DirectIp.target,
port: config.DirectIp.port.toString(),
timeout: config.DirectIp.timeout?.toString() || '30000',
expected_result: config.DirectIp.expected_result
};
} else if ('Ping' in config) {
return {
target: config.Ping.target,
port: config.Ping.port?.toString() || '',
attempts: config.Ping.attempts?.toString() || '4',
timeout: config.Ping.timeout?.toString() || '30000',
expected_result: config.Ping.expected_result
};
} else if ('ServiceHealth' in config) {
return {
target: config.ServiceHealth.target,
port: config.ServiceHealth.port?.toString() || '',
path: config.ServiceHealth.path || '/',
timeout: config.ServiceHealth.timeout?.toString() || '30000',
expected_result: config.ServiceHealth.expected_result
};
} else if ('DnsResolution' in config) {
return {
domain: config.DnsResolution.domain,
timeout: config.DnsResolution.timeout?.toString() || '30000',
expected_result: config.DnsResolution.expected_result
};
} else if ('VpnConnectivity' in config) {
return {
target: config.VpnConnectivity.target,
port: config.VpnConnectivity.port?.toString() || '51820',
timeout: config.VpnConnectivity.timeout?.toString() || '30000',
expected_result: config.VpnConnectivity.expected_result
};
} else if ('VpnTunnel' in config) {
return {
expected_subnet: config.VpnTunnel.expected_subnet,
timeout: config.VpnTunnel.timeout?.toString() || '30000',
expected_result: config.VpnTunnel.expected_result
};
}
return {};
}
function getTestConfigForType(testType: TestType) {
@@ -91,7 +168,7 @@
case 'Connectivity':
return {
Connectivity: {
base: baseConfig,
...baseConfig,
target: testConfig.target || nodeTarget,
port: testConfig.port ? parseInt(testConfig.port) : undefined,
protocol: 'http'
@@ -100,7 +177,7 @@
case 'DirectIp':
return {
DirectIp: {
base: baseConfig,
...baseConfig,
target: node?.ip || '',
port: parseInt(testConfig.port) || 80
}
@@ -108,7 +185,7 @@
case 'Ping':
return {
Ping: {
base: baseConfig,
...baseConfig,
target: testConfig.target || nodeTarget,
port: testConfig.port ? parseInt(testConfig.port) : undefined,
attempts: parseInt(testConfig.attempts) || 4
@@ -117,20 +194,20 @@
case 'WellknownIp':
return {
WellknownIp: {
base: baseConfig
...baseConfig
}
};
case 'DnsResolution':
return {
DnsResolution: {
base: baseConfig,
...baseConfig,
domain: testConfig.domain || 'example.com'
}
};
case 'DnsOverHttps':
return {
DnsOverHttps: {
base: baseConfig,
...baseConfig,
target: 'https://1.1.1.1/dns-query',
domain: testConfig.domain || 'example.com',
service_type: 'cloudflare'
@@ -139,7 +216,7 @@
case 'ServiceHealth':
return {
ServiceHealth: {
base: baseConfig,
...baseConfig,
target: testConfig.target || nodeTarget,
port: parseInt(testConfig.port) || 80,
path: testConfig.path || '/',
@@ -149,7 +226,7 @@
case 'VpnConnectivity':
return {
VpnConnectivity: {
base: baseConfig,
...baseConfig,
target: testConfig.target || nodeTarget,
port: parseInt(testConfig.port) || 51820
}
@@ -157,7 +234,7 @@
case 'VpnTunnel':
return {
VpnTunnel: {
base: baseConfig,
...baseConfig,
expected_subnet: testConfig.expected_subnet || '10.100.0.0/24'
}
};
@@ -178,10 +255,12 @@
test_config: getTestConfigForType(selectedTestType),
criticality: selectedCriticality,
monitor_interval_minutes: monitorInterval ? parseInt(monitorInterval) : null,
enabled: true
enabled: enabled
};
const response = await fetch('/api/tests/assign-test', {
const endpoint = isEditMode ? '/api/tests/update-assignment' : '/api/tests/assign-test';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -191,21 +270,58 @@
if (response.ok) {
const result = await response.json();
onAssigned(result.data.node, result.data.warning);
onAssigned(result.data.node || result.data, result.data.warning);
resetForm();
onClose();
} else {
const error = await response.json();
alert(`Failed to assign test: ${error.error || 'Unknown error'}`);
alert(`Failed to ${isEditMode ? 'update' : 'assign'} test: ${error.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Error assigning test:', error);
alert('Failed to assign test. Please try again.');
console.error(`Error ${isEditMode ? 'updating' : 'assigning'} test:`, error);
alert(`Failed to ${isEditMode ? 'update' : 'assign'} test. Please try again.`);
} finally {
loading = false;
}
}
async function handleDelete() {
if (!node || !test) return;
if (!confirm(`Are you sure you want to remove the ${getTestTypeDisplayName(test.test_type)} test from ${node.name}?`)) {
return;
}
deleting = true;
try {
const response = await fetch('/api/tests/unassign-test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
node_id: node.id,
test_type: test.test_type
}),
});
if (response.ok) {
const result = await response.json();
onAssigned(result.data); // Pass the updated node
onClose();
} else {
const error = await response.json();
alert(`Failed to remove test: ${error.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Error removing test:', error);
alert('Failed to remove test. Please try again.');
} finally {
deleting = false;
}
}
function handleClose() {
resetForm();
onClose();
@@ -217,7 +333,7 @@
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-white">
Assign Test to {node?.name || 'Node'}
{isEditMode ? `Edit ${node?.name || 'Node'} ${test?.test_type ? getTestTypeDisplayName(test.test_type) : ''} Test` : `Assign Test to '${node?.name || 'Node'}'`}
</h2>
<button
on:click={handleClose}
@@ -233,7 +349,7 @@
</div>
{:else}
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<!-- Test Type Selection -->
<!-- Test Type Selection (disabled in edit mode) -->
<div>
<label for="test_type" class="block text-sm font-medium text-gray-300 mb-1">
Test Type
@@ -241,7 +357,8 @@
<select
id="test_type"
bind:value={selectedTestType}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isEditMode}
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-800 disabled:cursor-not-allowed"
>
{#each testTypes as testType}
<option value={testType}>
@@ -286,6 +403,21 @@
{/if}
</div>
<!-- Enabled Toggle -->
<div>
<label class="flex items-center space-x-2">
<input
type="checkbox"
bind:checked={enabled}
class="rounded bg-gray-700 border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm font-medium text-gray-300">Enabled</span>
</label>
<p class="text-xs text-gray-400 mt-1">
Disabled tests will not run during monitoring or diagnostics
</p>
</div>
<!-- Criticality -->
<div>
<label for="criticality" class="block text-sm font-medium text-gray-300 mb-1">
@@ -462,21 +594,39 @@
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
on:click={handleClose}
class="px-4 py-2 text-gray-300 hover:text-white border border-gray-600 rounded-md hover:border-gray-500 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading || isTestAlreadyAssigned}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Assigning...' : 'Assign Test'}
</button>
<div class="flex justify-between pt-4">
<!-- Delete button (only in edit mode) -->
<div>
{#if isEditMode}
<button
type="button"
on:click={handleDelete}
disabled={deleting}
class="flex items-center gap-2 px-4 py-2 text-red-300 hover:text-red-200 border border-red-600 rounded-md hover:border-red-500 transition-colors disabled:opacity-50"
>
<Trash2 size={16} />
{deleting ? 'Removing...' : 'Remove Test'}
</button>
{/if}
</div>
<!-- Save/Cancel buttons -->
<div class="flex space-x-3">
<button
type="button"
on:click={handleClose}
class="px-4 py-2 text-gray-300 hover:text-white border border-gray-600 rounded-md hover:border-gray-500 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading || (!isEditMode && isTestAlreadyAssigned)}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors"
>
{loading ? (isEditMode ? 'Updating...' : 'Assigning...') : (isEditMode ? 'Update Test' : 'Assign Test')}
</button>
</div>
</div>
</form>
{/if}

View File

@@ -1,186 +0,0 @@
<!-- src/lib/components/nodes/NodeCard.svelte -->
<script lang="ts">
import { Edit, Settings, Trash2, Server, SquareActivity } from 'lucide-svelte';
import type { Node } from "$lib/types/nodes";
import { getNodeStatusDisplayName, getNodeStatusColor } from "$lib/types/nodes";
import { getNodeTypeDisplayName } from "$lib/types/nodes";
export let node: Node;
export let groupNames: string[] = [];
export let onEdit: (node: Node) => void = () => {};
export let onDelete: (node: Node) => void = () => {};
export let onAssignTest: (node: Node) => void = () => {};
function handleEdit() {
onEdit(node);
}
function handleAssignTest() {
onAssignTest(node);
}
function handleDelete() {
onDelete(node);
}
// Get the display status - monitoring status takes precedence if disabled
function getDisplayStatus() {
if (!node.monitoring_enabled) {
return 'Monitoring Disabled';
}
return getNodeStatusDisplayName(node.current_status);
}
// Get the status color - gray for monitoring disabled, otherwise node status color
function getDisplayStatusColor() {
if (!node.monitoring_enabled) {
return 'text-gray-400';
}
return getNodeStatusColor(node.current_status);
}
</script>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-gray-600 transition-colors flex flex-col h-full">
<!-- Header -->
<div class="flex justify-between items-start mb-4">
<div class="flex items-center space-x-3">
<Server size={24} class="text-blue-400" />
<div>
<h3 class="text-lg font-semibold text-white">{node.name}</h3>
<p class="text-sm text-gray-400">
{node.node_type ? getNodeTypeDisplayName(node.node_type) : 'Unknown Device'}
</p>
</div>
</div>
<span class="text-sm font-medium {getDisplayStatusColor()}">
{getDisplayStatus()}
</span>
</div>
<!-- Content - grows to fill available space -->
<div class="flex-grow space-y-3">
<!-- Connection Info -->
{#if node.ip || node.domain}
<div class="text-sm text-gray-300">
{#if node.ip}
<span>IP: {node.ip}</span>
{#if node.port}:{node.port}{/if}
{:else if node.domain}
<span>Domain: {node.domain}</span>
{#if node.port}:{node.port}{/if}
{/if}
</div>
{/if}
<!-- Capabilities -->
<div class="text-sm">
<span class="text-gray-400">Capabilities:</span>
{#if node.capabilities && node.capabilities.length > 0}
<span class="ml-2">
{#each node.capabilities as capability, i}
<span class="inline-block bg-blue-900/30 text-blue-300 px-2 py-1 rounded text-xs mr-1 mb-1">
{capability}
</span>
{/each}
</span>
{:else}
<span class="text-gray-500">No capabilities assigned</span>
{/if}
</div>
<!-- Groups -->
<div class="text-sm">
<span class="text-gray-400">Groups:</span>
{#if groupNames.length > 0}
<span class="ml-2">
{#each groupNames as groupName, i}
<span class="inline-block bg-green-900/30 text-green-300 px-2 py-1 rounded text-xs mr-1 mb-1">
{groupName}
</span>
{/each}
</span>
{:else}
<span class="text-gray-500">No groups assigned</span>
{/if}
</div>
<!-- Tests -->
<div class="text-sm">
<span class="text-gray-400">Tests:</span>
<span class="ml-2">
{#if node.assigned_tests && node.assigned_tests.length > 0}
<div class="mt-1 space-y-1">
{#each node.assigned_tests as test}
<div class="flex items-center justify-between">
<span class="text-gray-300">{test.test_type}</span>
<div class="flex items-center space-x-2">
<span class="text-xs px-2 py-1 rounded {
test.criticality === 'Critical' ? 'bg-red-900/30 text-red-300' :
test.criticality === 'Important' ? 'bg-yellow-900/30 text-yellow-300' :
'bg-blue-900/30 text-blue-300'
}">
{test.criticality}
</span>
{#if test.monitor_interval_minutes}
<span class="text-xs text-gray-500">
{test.monitor_interval_minutes}m
</span>
{/if}
{#if !test.enabled}
<span class="text-xs text-gray-500">(disabled)</span>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<span class="text-gray-500">No tests assigned</span>
{/if}
</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-between items-center pt-4 mt-4 border-t border-gray-700">
<div class="flex space-x-2">
<button
on:click={handleEdit}
class="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Edit Node"
>
<Edit size={16} />
</button>
<button
on:click={handleAssignTest}
class="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Assign Test"
>
<SquareActivity size={16} />
</button>
</div>
<button
on:click={handleDelete}
class="p-2 text-gray-400 hover:text-red-400 hover:bg-red-900/20 rounded transition-colors"
title="Delete Node"
>
<Trash2 size={16} />
</button>
</div>
</div>
<style>
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:disabled:hover {
background-color: transparent;
color: inherit;
}
</style>

View File

@@ -3,16 +3,18 @@
import { Plus, Search } from 'lucide-svelte';
import { nodes, nodeActions, loading, error } from '../../stores/nodes';
import { nodeGroups, nodeGroupActions } from '../../stores/node-groups';
import type { Node } from '../../types/nodes';
import NodeCard from '../nodes/NodeCard.svelte';
import type { Node, AssignedTest } from '../../types/nodes';
import { getTestTypeDisplayName } from '../../types/tests';
import NodeCard from '../cards/NodeCard.svelte';
import NodeEditor from '../modals/NodeEditor.svelte';
import TestAssignment from '../modals/TestAssignment.svelte';
let searchTerm = '';
let showNodeEditor = false;
let showTestAssignment = false;
let showTestEditor = false;
let editingNode: Node | null = null;
let assigningTestNode: Node | null = null;
let editingTest: AssignedTest | null = null;
$: filteredNodes = $nodes.filter((node: Node) =>
node.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -55,7 +57,44 @@
function handleAssignTest(node: Node) {
assigningTestNode = node;
showTestAssignment = true;
showTestEditor = true;
}
async function handleDeleteTest(node: Node, test: AssignedTest) {
if (!confirm(`Are you sure you want to remove the ${getTestTypeDisplayName(test.test_type)} test from ${node.name}?`)) {
return;
}
try {
const response = await fetch('/api/tests/unassign-test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
node_id: node.id,
test_type: test.test_type
}),
});
if (response.ok) {
// Refresh nodes to get the updated node list
await nodeActions.loadNodes();
} else {
const error = await response.json();
alert(`Failed to remove test: ${error.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Error removing test:', error);
alert('Failed to remove test. Please try again.');
}
}
// Also update your handleEditTest function to clear editingTest when closing
function handleEditTest(node: Node, test: AssignedTest) {
assigningTestNode = node;
editingTest = test;
showTestEditor = true;
}
// Updated to handle function props instead of events
@@ -86,7 +125,7 @@
// Refresh nodes to get the updated node with the new test
await nodeActions.loadNodes();
showTestAssignment = false;
showTestEditor = false;
assigningTestNode = null;
}
@@ -96,8 +135,9 @@
}
function handleCloseTestAssignment() {
showTestAssignment = false;
showTestEditor = false;
assigningTestNode = null;
editingTest = null;
}
</script>
@@ -197,6 +237,8 @@
onEdit={handleEditNode}
onDelete={handleDeleteNode}
onAssignTest={handleAssignTest}
onEditTest={handleEditTest}
onDeleteTest={handleDeleteTest}
/>
{/each}
</div>
@@ -213,8 +255,9 @@
/>
<TestAssignment
isOpen={showTestAssignment}
isOpen={showTestEditor}
node={assigningTestNode}
test={editingTest}
onAssigned={handleTestAssign}
onClose={handleCloseTestAssignment}
/>

View File

@@ -1,6 +1,40 @@
import type { Node } from "./nodes";
import type { TestType, TestResult } from "./tests";
// Components
export interface CardAction {
label: string;
icon: any;
color?: string;
hoverColor?: string;
bgHover?: string;
onClick: () => void;
disabled?: boolean;
}
export interface CardSection {
label: string;
value: string;
}
export interface CardList {
label: string;
items: CardListItem[];
emptyText?: string;
renderItem?: (item: CardListItem) => string;
itemActions?: (item: CardListItem) => CardAction[];
}
export interface CardListItem {
id: string;
label: string;
metadata?: Record<string, any>;
color?: string;
bgColor?: string;
disabled?: boolean;
}
// API Response types
export interface ApiResponse<T> {
success: boolean;

View File

@@ -10,60 +10,64 @@ export type TestType = 'Connectivity' |
'DaemonCommand' |
'SshScript';
export interface BaseTestConfig {
export interface ConnectivityConfig {
timeout?: number;
expected_result: string;
}
export interface ConnectivityConfig {
base: BaseTestConfig;
target: string;
port?: number;
protocol?: string;
}
export interface DirectIpConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
target: string;
port: number;
}
export interface PingConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
target: string;
port?: number;
attempts?: number;
}
export interface WellknownIpConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
}
export interface DnsResolutionConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
domain: string;
}
export interface DnsOverHttpsConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
target: string;
domain: string;
service_type?: string;
}
export interface VpnConnectivityConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
target: string;
port?: number;
}
export interface VpnTunnelConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
expected_subnet: string;
}
export interface ServiceHealthConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
target: string;
port?: number;
path?: string;
@@ -71,7 +75,8 @@ export interface ServiceHealthConfig {
}
export interface DaemonCommandConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
command: string;
requires_confirmation?: boolean;
rollback_command?: string;
@@ -79,7 +84,8 @@ export interface DaemonCommandConfig {
}
export interface SshScriptConfig {
base: BaseTestConfig;
timeout?: number;
expected_result: string;
command: string;
ssh_target: string;
requires_confirmation?: boolean;

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import NodesTab from '../lib/components/tabs/NodesTab.svelte';
import DiagnosticsTab from '../lib/components/tabs/DiagnosticsTab.svelte';
import Sidebar from '../lib/components/shared/Sidebar.svelte';
import Sidebar from '../lib/components/common/Sidebar.svelte';
import { nodeActions } from '../lib/stores/nodes';
let activeTab = 'nodes';