mirror of
https://github.com/mayanayza/netvisor.git
synced 2025-12-10 08:24:08 -06:00
ui tweaks
This commit is contained in:
151
src/lib/components/cards/NodeCard.svelte
Normal file
151
src/lib/components/cards/NodeCard.svelte
Normal 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} />
|
||||
103
src/lib/components/cards/NodeGroupCard.svelte
Normal file
103
src/lib/components/cards/NodeGroupCard.svelte
Normal 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} />
|
||||
118
src/lib/components/common/Card.svelte
Normal file
118
src/lib/components/common/Card.svelte
Normal 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>
|
||||
87
src/lib/components/common/EditModal.svelte
Normal file
87
src/lib/components/common/EditModal.svelte
Normal 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}
|
||||
137
src/lib/components/common/ListManager.svelte
Normal file
137
src/lib/components/common/ListManager.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user