initial commit

This commit is contained in:
Maya
2025-07-29 20:29:18 -04:00
commit 2045c2fea3
47 changed files with 9258 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

16
.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

40
eslint.config.js Normal file
View File

@@ -0,0 +1,40 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

4184
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "netzoot-ui",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/vite": "^4.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

55
src/app.css Normal file
View File

@@ -0,0 +1,55 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
margin: 0;
padding: 0;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%);
min-height: 100vh;
}
}
@layer components {
.btn-primary {
@apply bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700;
@apply text-white font-semibold px-6 py-3 rounded-xl;
@apply transition-all duration-300 ease-in-out;
@apply shadow-lg hover:shadow-xl transform hover:-translate-y-1;
}
.card-modern {
@apply bg-gray-800 bg-opacity-80 backdrop-blur-sm border border-gray-700;
@apply rounded-2xl p-6 shadow-xl transition-all duration-300 ease-in-out;
}
}
@layer utilities {
.text-gradient {
background: linear-gradient(135deg, #60a5fa 0%, #a855f7 50%, #ec4899 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.glass {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
/* Custom animations */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-float {
animation: float 3s ease-in-out infinite;
}

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

13
src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Netzoot</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Activity, Network, Zap } from 'lucide-svelte';
</script>
<header class="text-center mb-12">
<!-- Main Title Section -->
<div class="flex items-center justify-center gap-4 mb-6">
<div class="p-4 bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl shadow-lg shadow-blue-500/25 animate-pulse">
<Network class="w-10 h-10 text-white" />
</div>
<div class="text-left">
<h1 class="text-5xl font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent leading-tight">
Netzoot
</h1>
</div>
</div>
</header>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { modal, modalActions } from '../stores/ui';
import { onMount } from 'svelte';
let backdropElement: HTMLDivElement | undefined;
onMount(() => {
// Handle escape key and backdrop clicks
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && $modal.isOpen) {
modalActions.close();
}
}
function handleClick(event: MouseEvent): void {
// Check if click is on the backdrop (not on modal content)
if (backdropElement && event.target === backdropElement) {
modalActions.close();
}
}
if ($modal.isOpen) {
document.addEventListener('keydown', handleKeydown);
document.addEventListener('click', handleClick);
}
return () => {
document.removeEventListener('keydown', handleKeydown);
document.removeEventListener('click', handleClick);
};
});
// Check if this is a small dialog (like ConfirmDialog)
$: isSmallDialog = $modal.component?.name === 'ConfirmDialog' || $modal.title.includes('Confirm');
// Re-setup event listeners when modal state changes
$: if ($modal.isOpen) {
// Event listeners are handled in onMount
}
</script>
{#if $modal.isOpen}
<!-- Modal backdrop -->
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
bind:this={backdropElement}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
>
{#if isSmallDialog}
<!-- Small dialog - no wrapper, just the component -->
<div role="document">
{#if $modal.component}
<svelte:component this={$modal.component} {...$modal.props} />
{/if}
</div>
{:else}
<!-- Regular modal with header -->
<div
class="bg-gray-800 rounded-xl border border-gray-700 shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-visible flex flex-col"
role="document"
>
<!-- Modal header -->
<div class="flex items-center justify-between p-6 border-b border-gray-700">
<h2 id="modal-title" class="text-xl font-semibold text-white">
{$modal.title}
</h2>
<button
type="button"
class="p-2 hover:bg-gray-700 rounded-lg transition-colors"
on:click={modalActions.close}
aria-label="Close modal"
>
<X class="w-5 h-5 text-gray-400" />
</button>
</div>
<!-- Modal body - allow overflow for dropdowns -->
<div class="flex-1 overflow-y-auto overflow-x-visible" style="overflow: visible;">
{#if $modal.component}
<svelte:component this={$modal.component} {...$modal.props} />
{/if}
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { X, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-svelte';
import { notifications, notificationActions } from '../stores/ui';
import type { NotificationItem } from '../types';
function getIcon(type: NotificationItem['type']) {
switch (type) {
case 'success': return CheckCircle;
case 'error': return XCircle;
case 'warning': return AlertTriangle;
case 'info': return Info;
default: return Info;
}
}
function getColorClasses(type: NotificationItem['type']) {
switch (type) {
case 'success': return 'bg-green-600 border-green-500 text-white';
case 'error': return 'bg-red-600 border-red-500 text-white';
case 'warning': return 'bg-yellow-600 border-yellow-500 text-white';
case 'info': return 'bg-blue-600 border-blue-500 text-white';
default: return 'bg-gray-600 border-gray-500 text-white';
}
}
function dismiss(id: string) {
notificationActions.remove(id);
}
</script>
<!-- Notification Container -->
<div class="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
{#each $notifications as notification (notification.id)}
<div
class="flex items-start gap-3 p-4 rounded-lg border shadow-lg transition-all duration-300 ease-in-out {getColorClasses(notification.type)}"
style="animation: slideInRight 0.3s ease-out"
>
<!-- Icon -->
<svelte:component this={getIcon(notification.type)} class="w-5 h-5 mt-0.5 flex-shrink-0" />
<!-- Message -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium leading-tight">
{notification.message}
</p>
</div>
<!-- Dismiss button -->
<button
on:click={() => dismiss(notification.id)}
class="p-1 hover:bg-white/20 rounded transition-colors flex-shrink-0"
aria-label="Dismiss notification"
>
<X class="w-4 h-4" />
</button>
</div>
{/each}
</div>
<style>
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { Activity, Server, GitBranch, Network } from 'lucide-svelte';
import { activeTab } from '../stores/ui';
const navigationItems = [
{
id: 'diagnostics',
name: 'Diagnostics',
icon: Activity,
description: 'Run network tests'
},
{
id: 'nodes',
name: 'Nodes',
icon: Server,
description: 'Manage network resources'
},
{
id: 'tests',
name: 'Tests',
icon: GitBranch,
description: 'Configure network tests'
}
];
function switchTab(tabId: string) {
activeTab.set(tabId);
}
</script>
<div class="w-64 bg-gray-800 border-r border-gray-700 flex flex-col">
<!-- Logo and Title -->
<div class="p-6 border-b border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg">
<Network class="w-6 h-6 text-white" />
</div>
<div>
<h1 class="text-xl font-bold text-white">Netzoot</h1>
</div>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 p-4">
<div class="space-y-2">
{#each navigationItems as item}
<button
on:click={() => switchTab(item.id)}
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-colors"
class:bg-blue-600={$activeTab === item.id}
class:text-white={$activeTab === item.id}
class:shadow-lg={$activeTab === item.id}
class:text-gray-300={$activeTab !== item.id}
class:hover:bg-gray-700={$activeTab !== item.id}
class:hover:text-white={$activeTab !== item.id}
>
<div class="p-1.5 rounded {$activeTab === item.id ? 'bg-white bg-opacity-20' : 'bg-gray-600'}">
<svelte:component this={item.icon} class="w-4 h-4" />
</div>
<div class="flex-1">
<div class="font-medium text-sm">{item.name}</div>
<div class="text-xs opacity-75">{item.description}</div>
</div>
</button>
{/each}
</div>
</nav>
<!-- Footer -->
<div class="p-4 border-t border-gray-700">
<div class="text-xs text-gray-500 text-center">
v1.0.0
</div>
</div>
</div>

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import { ChevronDown, ChevronUp, Zap } from 'lucide-svelte';
import { CHECK_TYPES, CATEGORY_ICONS } from '$lib/stores/checks';
import { createEventDispatcher, tick } from 'svelte';
export let value: string = '';
export let placeholder: string = 'Select check type...';
const dispatch = createEventDispatcher<{
change: { value: string };
}>();
let isOpen: boolean = false;
let triggerElement: HTMLButtonElement | undefined;
let dropdownElement: HTMLDivElement | undefined;
$: selectedType = CHECK_TYPES[value];
async function toggleDropdown(): Promise<void> {
isOpen = !isOpen;
if (isOpen) {
await tick();
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
positionDropdown();
});
}
}
function positionDropdown(): void {
if (!isOpen || !triggerElement || !dropdownElement) return;
const rect = triggerElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const dropdownHeight = 250; // Approximate max height
// Calculate space above and below
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
// Position dropdown
if (spaceBelow >= 200 || spaceBelow > spaceAbove) {
// Position below
dropdownElement.style.top = (rect.bottom + window.scrollY + 4) + 'px';
dropdownElement.style.maxHeight = Math.min(spaceBelow - 20, dropdownHeight) + 'px';
} else {
// Position above
const maxHeight = Math.min(spaceAbove - 20, dropdownHeight);
dropdownElement.style.top = (rect.top + window.scrollY - maxHeight - 4) + 'px';
dropdownElement.style.maxHeight = maxHeight + 'px';
}
dropdownElement.style.left = (rect.left + window.scrollX) + 'px';
dropdownElement.style.width = rect.width + 'px';
}
function selectType(type: string): void {
value = type;
isOpen = false;
dispatch('change', { value: type });
}
function handleClickOutside(event: MouseEvent): void {
const target = event.target as Node | null;
if (triggerElement && !triggerElement.contains(target) &&
dropdownElement && !dropdownElement.contains(target)) {
isOpen = false;
}
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
isOpen = false;
}
}
function handleScroll(): void {
if (isOpen && triggerElement && dropdownElement) {
// Use requestAnimationFrame to prevent infinite loops
requestAnimationFrame(() => {
positionDropdown();
});
}
}
// Action to position dropdown when it mounts
function positionOnMount(node: HTMLElement) {
requestAnimationFrame(() => {
positionDropdown();
});
return {
destroy() {
// Cleanup if needed
}
};
}
// Group checks by category
$: groupedChecks = Object.entries(CHECK_TYPES).reduce((groups: Record<string, Array<[string, any]>>, [type, config]) => {
const category = config.category;
if (!groups[category]) groups[category] = [];
groups[category].push([type, config]);
return groups;
}, {});
</script>
<svelte:window
on:click={handleClickOutside}
on:keydown={handleKeydown}
on:scroll={handleScroll}
on:resize={handleScroll}
/>
<div class="relative">
<!-- Trigger Button -->
<button
type="button"
bind:this={triggerElement}
on:click={toggleDropdown}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 flex items-center justify-between hover:bg-gray-650 transition-colors"
>
<div class="flex items-center gap-2 flex-1 text-left">
{#if selectedType}
<div><div class="font-medium">{selectedType.name}</div></div>
{:else}
<div><div class="text-gray-400">{placeholder}</div></div>
{/if}
</div>
<div class="flex items-center">
{#if isOpen}
<ChevronUp class="w-4 h-4 text-gray-400" />
{:else}
<ChevronDown class="w-4 h-4 text-gray-400" />
{/if}
</div>
</button>
</div>
<!-- Fixed positioned dropdown outside modal -->
{#if isOpen}
<div
bind:this={dropdownElement}
class="fixed z-[9999] bg-gray-800 border border-gray-600 rounded-lg shadow-xl overflow-auto"
style="top: 0px; left: 0px; width: 300px; max-height: 250px;"
use:positionOnMount
>
{#each Object.entries(groupedChecks) as [category, checks]}
<div class="p-2">
<!-- Category Header -->
<div class="px-2 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider border-b border-gray-700 mb-2">
{category}
</div>
<!-- Check Options -->
{#each checks as [type, config]}
<button
type="button"
on:click={() => selectType(type)}
class="w-full text-left px-3 py-2 rounded hover:bg-gray-700 transition-colors group"
class:bg-blue-900={value === type}
class:bg-opacity-30={value === type}
>
<div class="flex items-start gap-3">
<div class="p-1 rounded {value === type ? 'bg-blue-600' : 'bg-gray-600 group-hover:bg-gray-500'} transition-colors">
<svelte:component this={CATEGORY_ICONS[category]} class="w-3 h-3 text-white" />
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-white text-sm mb-1">
{config.name}
</div>
<div class="text-xs text-gray-400 leading-tight">
{config.description}
</div>
</div>
</div>
</button>
{/each}
</div>
{/each}
</div>
{/if}
<!-- Selected Type Description (shown below dropdown) -->
{#if selectedType}
<div class="mt-2 p-3 bg-blue-900/20 border border-blue-700/30 rounded-lg">
<div class="flex items-start gap-2">
<div>
<div class="text-xs text-blue-300 leading-relaxed">
{selectedType.details}
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { AlertTriangle, X } from 'lucide-svelte';
import { modalActions } from '../../stores/ui';
export let title = 'Confirm Action';
export let message = 'Are you sure you want to proceed?';
export let confirmText = 'Confirm';
export let cancelText = 'Cancel';
export let onConfirm: () => void | Promise<void> = () => {};
export let danger = false;
async function handleConfirm() {
try {
await onConfirm();
modalActions.close();
} catch (error) {
// Don't close modal if there's an error
console.error('Confirmation action failed:', error);
}
}
function handleCancel() {
modalActions.close();
}
</script>
<div class="bg-gray-800 rounded-xl border border-gray-700 w-96 max-w-[90vw] mx-auto shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between p-5 border-b border-gray-700">
<h3 class="text-lg font-semibold text-white">{title}</h3>
<button
on:click={handleCancel}
class="p-1 hover:bg-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors"
>
<X class="w-4 h-4" />
</button>
</div>
<!-- Content -->
<div class="p-5">
<!-- Icon and Message -->
<div class="flex items-start gap-4 mb-6">
<div class="p-2 {danger ? 'bg-red-600/20 border border-red-600/30' : 'bg-yellow-600/20 border border-yellow-600/30'} rounded-lg shrink-0">
<AlertTriangle class="w-5 h-5 {danger ? 'text-red-400' : 'text-yellow-400'}" />
</div>
<p class="text-gray-300 leading-relaxed">
{message}
</p>
</div>
<!-- Actions -->
<div class="flex gap-3 justify-end">
<button
type="button"
on:click={handleCancel}
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
{cancelText}
</button>
<button
type="button"
on:click={handleConfirm}
class="px-4 py-2 {danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'} text-white rounded-lg transition-colors"
>
{confirmText}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,218 @@
<script lang="ts">
import { Save, AlertCircle } from 'lucide-svelte';
import { nodeActions, validateNode } from '../../stores/nodes';
import { modalActions, notificationActions } from '../../stores/ui';
interface NodeData {
id?: string;
name: string;
domain: string;
ip: string;
defaultPort: number;
path: string;
description: string;
}
export let mode: 'create' | 'edit' = 'create';
export let node: NodeData | null = null;
// Initialize form data - no type field, no NODE_TYPES logic
let formData: NodeData = {
name: '',
domain: '',
ip: '',
defaultPort: 443,
path: '',
description: '',
...node
};
let errors: string[] = [];
let saving: boolean = false;
function validateForm(): boolean {
errors = validateNode(formData);
return errors.length === 0;
}
async function handleSave(): Promise<void> {
if (!validateForm()) {
return;
}
saving = true;
try {
// Clean up form data - remove empty strings and convert port to number
const cleanData: Partial<NodeData> = { ...formData };
Object.keys(cleanData).forEach((key) => {
const typedKey = key as keyof NodeData;
if (cleanData[typedKey] === '') {
delete cleanData[typedKey];
}
});
if (cleanData.defaultPort) {
cleanData.defaultPort = parseInt(cleanData.defaultPort.toString());
}
if (mode === 'create') {
await nodeActions.add(cleanData as NodeData);
notificationActions.success(`Created node: ${cleanData.name}`);
} else {
if (!formData.id) {
throw new Error('Node ID is required for updates');
}
await nodeActions.update(formData.id, cleanData);
notificationActions.success(`Updated node: ${cleanData.name}`);
}
modalActions.close();
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
notificationActions.error(`Failed to save node: ${errorMessage}`);
} finally {
saving = false;
}
}
function handleCancel(): void {
modalActions.close();
}
</script>
<div class="p-6">
<form on:submit|preventDefault={handleSave} class="space-y-6">
<!-- Name -->
<div>
<label for="node-name" class="block text-sm font-medium text-gray-300 mb-2">
Name <span class="text-red-400">*</span>
</label>
<input
id="node-name"
type="text"
bind:value={formData.name}
required
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Google DNS, Cloudflare, Pi-hole"
/>
</div>
<!-- Domain -->
<div>
<label for="node-domain" class="block text-sm font-medium text-gray-300 mb-2">
Domain
</label>
<input
id="node-domain"
type="text"
bind:value={formData.domain}
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., google.com, 1.1.1.1, localhost"
/>
<p class="text-xs text-gray-400 mt-1">
Primary hostname or domain for this node
</p>
</div>
<!-- IP Address -->
<div>
<label for="node-ip" class="block text-sm font-medium text-gray-300 mb-2">
IP Address
</label>
<input
id="node-ip"
type="text"
bind:value={formData.ip}
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., 8.8.8.8, 192.168.1.1"
/>
<p class="text-xs text-gray-400 mt-1">
Static IP address for direct connectivity tests
</p>
</div>
<!-- Default Port -->
<div>
<label for="node-port" class="block text-sm font-medium text-gray-300 mb-2">
Default Port
</label>
<input
id="node-port"
type="number"
bind:value={formData.defaultPort}
min="1"
max="65535"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="443"
/>
<p class="text-xs text-gray-400 mt-1">
Default port for connectivity tests (1-65535)
</p>
</div>
<!-- Path -->
<div>
<label for="node-path" class="block text-sm font-medium text-gray-300 mb-2">
Path
</label>
<input
id="node-path"
type="text"
bind:value={formData.path}
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., /dns-query, /admin, /api/health"
/>
<p class="text-xs text-gray-400 mt-1">
For DNS over HTTPS endpoints, service health paths, or API endpoints
</p>
</div>
<!-- Description -->
<div>
<label for="node-description" class="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
id="node-description"
bind:value={formData.description}
rows="3"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Optional description of this network node"
></textarea>
</div>
<!-- Validation Errors -->
{#if errors.length > 0}
<div class="bg-red-900/20 border border-red-700 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<AlertCircle class="w-4 h-4 text-red-400" />
<span class="font-medium text-red-400">Validation Errors</span>
</div>
<ul class="text-sm text-red-300 space-y-1">
{#each errors as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}
<!-- Action Buttons -->
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-700">
<button
type="button"
on:click={handleCancel}
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:opacity-50 text-white rounded-lg transition-colors"
>
<Save class="w-4 h-4" />
{saving ? 'Saving...' : mode === 'create' ? 'Create Node' : 'Update Node'}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,143 @@
<script>
import { ChevronDown, ChevronUp, Zap } from 'lucide-svelte';
import { TEST_TYPES } from '../../stores/topologies';
import { createEventDispatcher } from 'svelte';
export let value = '';
export let placeholder = 'Select test type...';
const dispatch = createEventDispatcher();
let isOpen = false;
let dropdownElement;
$: selectedType = TEST_TYPES[value];
function toggleDropdown() {
isOpen = !isOpen;
}
function selectType(type) {
value = type;
isOpen = false;
dispatch('change', { value: type });
}
function handleClickOutside(event) {
if (dropdownElement && !dropdownElement.contains(event.target)) {
isOpen = false;
}
}
function handleKeydown(event) {
if (event.key === 'Escape') {
isOpen = false;
}
}
// Get category for grouping
function getCategory(type) {
if (['connectivity_test', 'dns_resolution', 'dns_over_https', 'service_health', 'response_time', 'ping_test'].includes(type)) {
return 'Basic Tests';
} else if (['vpn_connectivity', 'vpn_tunnel'].includes(type)) {
return 'VPN Tests';
} else {
return 'Network Layer Tests';
}
}
// Group tests by category
$: groupedTests = Object.entries(TEST_TYPES).reduce((groups, [type, config]) => {
const category = getCategory(type);
if (!groups[category]) groups[category] = [];
groups[category].push([type, config]);
return groups;
}, {});
</script>
<svelte:window on:click={handleClickOutside} on:keydown={handleKeydown} />
<div class="relative" bind:this={dropdownElement}>
<!-- Trigger Button -->
<button
type="button"
on:click={toggleDropdown}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 flex items-center justify-between hover:bg-gray-650 transition-colors"
>
<div class="flex items-center gap-2 flex-1 text-left">
<Zap class="w-4 h-4 text-gray-400" />
<div>
{#if selectedType}
<div class="font-medium">{selectedType.name}</div>
{:else}
<div class="text-gray-400">{placeholder}</div>
{/if}
</div>
</div>
<div class="flex items-center">
{#if isOpen}
<ChevronUp class="w-4 h-4 text-gray-400" />
{:else}
<ChevronDown class="w-4 h-4 text-gray-400" />
{/if}
</div>
</button>
<!-- Dropdown Menu -->
{#if isOpen}
<div class="fixed z-[9999] w-full mt-1 bg-gray-800 border border-gray-600 rounded-lg shadow-xl max-h-96 overflow-auto" style="top: {dropdownElement?.getBoundingClientRect().bottom + window.scrollY + 4}px; left: {dropdownElement?.getBoundingClientRect().left + window.scrollX}px; width: {dropdownElement?.getBoundingClientRect().width}px">
{#each Object.entries(groupedTests) as [category, tests]}
<div class="p-2">
<!-- Category Header -->
<div class="px-2 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider border-b border-gray-700 mb-2">
{category}
</div>
<!-- Test Options -->
{#each tests as [type, config]}
<button
type="button"
on:click={() => selectType(type)}
class="w-full text-left px-3 py-3 rounded hover:bg-gray-700 transition-colors group"
class:bg-blue-900={value === type}
class:bg-opacity-30={value === type}
>
<div class="flex items-start gap-3">
<div class="p-1 rounded {value === type ? 'bg-blue-600' : 'bg-gray-600 group-hover:bg-gray-500'} transition-colors">
<Zap class="w-3 h-3 text-white" />
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-white text-sm mb-1">
{config.name}
</div>
<div class="text-xs text-gray-400 leading-relaxed">
{config.description}
</div>
</div>
</div>
</button>
{/each}
</div>
{/each}
</div>
{/if}
</div>
<!-- Selected Type Description (shown below dropdown) -->
{#if selectedType}
<div class="mt-2 p-3 bg-blue-900/20 border border-blue-700/30 rounded-lg">
<div class="flex items-start gap-2">
<div class="p-1 bg-blue-600 rounded">
<Zap class="w-3 h-3 text-white" />
</div>
<div>
<div class="font-medium text-blue-200 text-sm mb-1">
{selectedType.name}
</div>
<div class="text-xs text-blue-300 leading-relaxed">
{selectedType.description}
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,520 @@
<script>
import { Save, AlertCircle, Plus, Trash2, Move, ChevronDown, ChevronRight, Play, Server, Zap } from 'lucide-svelte';
import { topologyActions, validateTopology, TEST_TYPES, createBlankTopology } from '../../stores/topologies';
import { nodes } from '../../stores/nodes';
import { modalActions, notificationActions } from '../../stores/ui';
import { getDefaultTestConfig } from '../../stores/topologies';
import TestTypeSelector from './TestTypeSelector.svelte';
export let mode = 'create'; // 'create' or 'edit'
export let topology = null;
// Initialize form data
let formData = {
name: '',
description: '',
version: '1.0',
layers: [],
...topology
};
// Ensure layers have IDs and proper structure
if (formData.layers) {
formData.layers = formData.layers.map(layer => ({
...layer,
id: layer.id || crypto.randomUUID(),
tests: layer.tests || [],
failureActions: layer.failureActions || []
}));
}
// If creating and no layers, add a default layer
if (mode === 'create' && formData.layers.length === 0) {
const blank = createBlankTopology();
formData.layers = blank.layers;
}
let errors = [];
let saving = false;
let expandedLayers = new Set(formData.layers.map(l => l.id));
let expandedTests = new Set();
// Available node references for interpolation
$: nodeOptions = $nodes.map(node => ({
id: node.id,
name: node.name,
domain: node.domain,
ip: node.ip,
defaultPort: node.defaultPort
}));
function validateForm() {
errors = validateTopology(formData, $nodes);
return errors.length === 0;
}
async function handleSave() {
if (!validateForm()) {
return;
}
saving = true;
try {
// Clean up form data
const cleanData = {
...formData,
layers: formData.layers.map(layer => ({
...layer,
tests: layer.tests.filter(test => test.type), // Remove incomplete tests
}))
};
if (mode === 'create') {
await topologyActions.add(cleanData);
notificationActions.success(`Created topology: ${cleanData.name}`);
} else {
await topologyActions.update(formData.id, cleanData);
notificationActions.success(`Updated topology: ${cleanData.name}`);
}
modalActions.close();
} catch (error) {
notificationActions.error(`Failed to save topology: ${error.message}`);
} finally {
saving = false;
}
}
function handleCancel() {
modalActions.close();
}
// Layer management
function addLayer() {
const newLayer = {
id: crypto.randomUUID(),
name: `Layer ${formData.layers.length + 1}`,
description: '',
tests: [],
};
formData.layers = [...formData.layers, newLayer];
expandedLayers.add(newLayer.id);
}
function removeLayer(layerId) {
formData.layers = formData.layers.filter(l => l.id !== layerId);
expandedLayers.delete(layerId);
}
function moveLayer(layerId, direction) {
const index = formData.layers.findIndex(l => l.id === layerId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= formData.layers.length) return;
const layers = [...formData.layers];
[layers[index], layers[newIndex]] = [layers[newIndex], layers[index]];
formData.layers = layers;
}
function toggleLayer(layerId) {
if (expandedLayers.has(layerId)) {
expandedLayers.delete(layerId);
} else {
expandedLayers.add(layerId);
}
expandedLayers = expandedLayers;
}
// Test management
function addTest(layerId) {
const layer = formData.layers.find(l => l.id === layerId);
if (!layer) return;
const newTest = {
id: crypto.randomUUID(),
type: 'connectivity_test',
config: {}
};
layer.tests = [...layer.tests, newTest];
expandedTests.add(newTest.id);
formData = formData; // Trigger reactivity
}
function removeTest(layerId, testIndex) {
const layer = formData.layers.find(l => l.id === layerId);
if (!layer) return;
const testId = layer.tests[testIndex]?.id;
if (testId) {
expandedTests.delete(testId);
}
layer.tests = layer.tests.filter((_, i) => i !== testIndex);
formData = formData;
}
function toggleTest(testId) {
if (expandedTests.has(testId)) {
expandedTests.delete(testId);
} else {
expandedTests.add(testId);
}
expandedTests = expandedTests;
}
function updateTestType(layerId, testIndex, newType) {
const layer = formData.layers.find(l => l.id === layerId);
if (!layer || !layer.tests[testIndex]) return;
// Reset config when changing test type
layer.tests[testIndex] = {
...layer.tests[testIndex],
type: newType,
config: getDefaultTestConfig(newType)
};
formData = formData;
}
// Node reference helpers
function getNodeReferenceOptions() {
return nodeOptions.map(node => [
{ value: `{{${node.id}}}`, label: `${node.name} (auto)` },
{ value: `{{${node.id}.domain}}`, label: `${node.name} (domain)` },
{ value: `{{${node.id}.ip}}`, label: `${node.name} (IP)` },
{ value: `{{${node.id}.defaultPort}}`, label: `${node.name} (port)` }
]).flat();
}
function insertNodeReference(event, layerId, testIndex, field) {
const target = event.target;
const value = target.value;
if (value.startsWith('{{')) {
const layer = formData.layers.find(l => l.id === layerId);
if (layer?.tests[testIndex]) {
layer.tests[testIndex].config[field] = value;
formData = formData;
}
}
}
</script>
<div class="p-6 max-h-[80vh] overflow-y-auto">
<form on:submit|preventDefault={handleSave} class="space-y-6">
<!-- Basic Information -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-white border-b border-gray-700 pb-2">
Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Name <span class="text-red-400">*</span>
</label>
<input
type="text"
bind:value={formData.name}
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., WireGuard + Pi-hole Setup"
required
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Version
</label>
<input
type="text"
bind:value={formData.version}
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., 1.0"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
bind:value={formData.description}
rows="2"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Describe what this topology tests"
></textarea>
</div>
</div>
<!-- Layers -->
<div class="space-y-4">
<div class="flex items-center justify-between border-b border-gray-700 pb-2">
<h3 class="text-lg font-semibold text-white">
Test Layers ({formData.layers.length})
</h3>
<button
type="button"
on:click={addLayer}
class="flex items-center gap-2 px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<Plus class="w-4 h-4" />
Add Layer
</button>
</div>
{#if formData.layers.length === 0}
<div class="text-center py-8 text-gray-400">
<Server class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No layers configured</p>
<p class="text-sm">Add your first test layer to get started</p>
</div>
{/if}
{#each formData.layers as layer, layerIndex (layer.id)}
<div class="bg-gray-800 border border-gray-700 rounded-lg overflow-hidden">
<!-- Layer Header -->
<div class="p-4 border-b border-gray-700">
<div class="flex items-center justify-between">
<button
type="button"
on:click={() => toggleLayer(layer.id)}
class="flex items-center gap-2 text-white hover:text-blue-400 transition-colors"
>
{#if expandedLayers.has(layer.id)}
<ChevronDown class="w-4 h-4" />
{:else}
<ChevronRight class="w-4 h-4" />
{/if}
<span class="font-medium">{layer.name || `Layer ${layerIndex + 1}`}</span>
<span class="text-sm text-gray-400">({layer.tests?.length || 0} tests)</span>
</button>
<div class="flex items-center gap-2">
{#if layerIndex > 0}
<button
type="button"
on:click={() => moveLayer(layer.id, 'up')}
class="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white transition-colors"
title="Move up"
>
<Move class="w-4 h-4" />
</button>
{/if}
{#if layerIndex < formData.layers.length - 1}
<button
type="button"
on:click={() => moveLayer(layer.id, 'down')}
class="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white transition-colors"
title="Move down"
>
<Move class="w-4 h-4 rotate-180" />
</button>
{/if}
<button
type="button"
on:click={() => removeLayer(layer.id)}
class="p-1 hover:bg-red-700 rounded text-gray-400 hover:text-red-400 transition-colors"
title="Delete layer"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- Layer Content -->
{#if expandedLayers.has(layer.id)}
<div class="p-4 space-y-4">
<!-- Layer Basic Info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Layer Name
</label>
<input
type="text"
bind:value={layer.name}
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Internet Connectivity"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<input
type="text"
bind:value={layer.description}
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="What does this layer test?"
/>
</div>
</div>
<!-- Tests -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="font-medium text-gray-300">Tests ({layer.tests?.length || 0})</h4>
<button
type="button"
on:click={() => addTest(layer.id)}
class="flex items-center gap-1 px-2 py-1 text-sm bg-green-600 hover:bg-green-700 text-white rounded transition-colors"
>
<Plus class="w-3 h-3" />
Add Test
</button>
</div>
{#each layer.tests || [] as test, testIndex (test.id || testIndex)}
<div class="bg-gray-900 border border-gray-600 rounded-lg overflow-hidden">
<!-- Test Header -->
<div class="p-3 border-b border-gray-600">
<div class="flex items-center justify-between">
<button
type="button"
on:click={() => toggleTest(test.id)}
class="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
>
{#if expandedTests.has(test.id)}
<ChevronDown class="w-3 h-3" />
{:else}
<ChevronRight class="w-3 h-3" />
{/if}
<Zap class="w-3 h-3" />
<span class="text-sm">{TEST_TYPES[test.type]?.name || test.type}</span>
</button>
<button
type="button"
on:click={() => removeTest(layer.id, testIndex)}
class="p-1 hover:bg-red-700 rounded text-gray-400 hover:text-red-400 transition-colors"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
</div>
<!-- Test Configuration -->
{#if expandedTests.has(test.id)}
<div class="p-3 space-y-3">
<!-- Test Type Selection -->
<div class="col-span-full">
<label class="block text-sm font-medium text-gray-300 mb-2">
Test Type
</label>
<TestTypeSelector
bind:value={test.type}
on:change={(e) => updateTestType(layer.id, testIndex, e.detail.value)}
/>
</div>
<!-- Dynamic Test Configuration -->
{#if test.type && TEST_TYPES[test.type]}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each TEST_TYPES[test.type].fields as field}
<div>
<label class="block text-xs font-medium text-gray-300 mb-1 capitalize">
{field.replace('_', ' ')}
</label>
{#if field === 'protocol'}
<select
bind:value={test.config[field]}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
{:else if field === 'service_type'}
<select
bind:value={test.config[field]}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="auto">Auto-detect</option>
<option value="google">Google</option>
<option value="cloudflare">Cloudflare</option>
<option value="pihole">Pi-hole</option>
</select>
{:else if field.includes('port') || field.includes('timeout') || field.includes('status') || field.includes('time') || field === 'attempts'}
<input
type="number"
bind:value={test.config[field]}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
placeholder={field === 'port' ? '443' : field === 'timeout' ? '5000' : ''}
/>
{:else}
<div class="relative">
<input
type="text"
bind:value={test.config[field]}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
placeholder={field === 'target' ? 'example.com or {{node-id}}' : field === 'domain' ? 'google.com' : ''}
/>
{#if (field === 'target' || field === 'domain') && nodeOptions.length > 0}
<select
on:change={(e) => insertNodeReference(e, layer.id, testIndex, field)}
class="absolute right-0 top-0 h-full bg-gray-600 border-l border-gray-500 text-white text-xs px-2 rounded-r opacity-75 hover:opacity-100"
>
<option value="">Node ref...</option>
{#each getNodeReferenceOptions() as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each}
</div>
<!-- Validation Errors -->
{#if errors.length > 0}
<div class="bg-red-900/30 border border-red-700/50 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<AlertCircle class="w-4 h-4 text-red-400" />
<span class="text-red-400 font-medium">Validation Errors</span>
</div>
<ul class="text-sm text-red-300 space-y-1">
{#each errors as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}
<!-- Form Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-gray-700">
<button
type="button"
on:click={handleCancel}
class="px-4 py-2 text-gray-300 hover:text-white transition-colors"
disabled={saving}
>
Cancel
</button>
<button
type="submit"
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
disabled={saving}
>
<Save class="w-4 h-4" />
{saving ? 'Saving...' : mode === 'create' ? 'Create Topology' : 'Update Topology'}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import { Trash2, Play, ChevronDown, ChevronRight } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { getDefaultCheckConfig, CHECK_TYPES, CATEGORY_ICONS } from '../../../stores/checks';
import type { Check, NetworkNode } from '$lib/types';
import CheckTypeSelector from '../CheckTypeSelector.svelte';
import NodeReferenceHelper from './NodeReferenceHelper.svelte';
export let check: Check;
export let index: number;
export let nodeOptions: NetworkNode[] = [];
export let expanded: boolean = false;
const dispatch = createEventDispatcher<{
checkUpdate: { index: number; check: Check };
toggleExpanded: void;
remove: void;
}>();
function updateCheckType(event: CustomEvent<{ value: string }>): void {
const newType = event.detail.value;
// Reset config when changing check type
check = {
...check,
type: newType,
config: getDefaultCheckConfig(newType)
};
dispatchUpdate();
}
function updateCheckConfig(field: string, value: any): void {
check.config = {
...check.config,
[field]: value
};
dispatchUpdate();
}
function handleNodeReferenceInsert(event: CustomEvent<{ field: string; reference: string }>): void {
const { field, reference } = event.detail;
const currentValue = (check.config as any)[field] || '';
updateCheckConfig(field, currentValue + reference);
}
function dispatchUpdate(): void {
dispatch('checkUpdate', { index, check });
}
</script>
<div class="border border-gray-600 rounded bg-gray-900/50">
<!-- Check Header -->
<div class="flex items-center justify-between p-2">
<button
type="button"
on:click={() => dispatch('toggleExpanded')}
class="flex items-center gap-2 text-sm text-gray-300 hover:text-white transition-colors flex-1 text-left"
>
{#if expanded}
<ChevronDown class="w-3 h-3" />
{:else}
<ChevronRight class="w-3 h-3" />
{/if}
{#if check.type && CHECK_TYPES[check.type]}
<div class="flex items-center gap-2">
<svelte:component this={CATEGORY_ICONS[CHECK_TYPES[check.type].category]} class="w-3 h-3" />
<span>{CHECK_TYPES[check.type].name}</span>
</div>
{:else}
<span class="text-gray-500">Unconfigured Check</span>
{/if}
</button>
<div class="flex items-center gap-1">
<button
type="button"
on:click={() => dispatch('toggleExpanded')}
class="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white transition-colors"
title="Test check"
>
<Play class="w-3 h-3" />
</button>
<button
type="button"
on:click={() => dispatch('remove')}
class="p-1 hover:bg-red-700 rounded text-gray-400 hover:text-red-400 transition-colors"
title="Remove check"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
</div>
<!-- Check Configuration -->
{#if expanded}
<div class="p-3 space-y-3">
<!-- Check Type Selection -->
<div>
<label for="check-type-{index}" class="block text-sm font-medium text-gray-300 mb-2">
Check Type
</label>
<div id="check-type-{index}">
<CheckTypeSelector
bind:value={check.type}
on:change={updateCheckType}
/>
</div>
</div>
<!-- Dynamic Check Configuration -->
{#if check.type && CHECK_TYPES[check.type]}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each CHECK_TYPES[check.type].fields as field}
<div>
<label for="check-{index}-{field}" class="block text-xs font-medium text-gray-300 mb-1 capitalize">
{field.replace('_', ' ')}
</label>
{#if field === 'protocol'}
<select
id="check-{index}-{field}"
bind:value={(check.config as any)[field]}
on:change={() => dispatchUpdate()}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
{:else if field === 'service_type'}
<select
id="check-{index}-{field}"
bind:value={(check.config as any)[field]}
on:change={() => dispatchUpdate()}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="auto">Auto-detect</option>
<option value="google">Google</option>
<option value="cloudflare">Cloudflare</option>
<option value="pihole">Pi-hole</option>
</select>
{:else if field.includes('port') || field.includes('timeout') || field.includes('status') || field.includes('time') || field === 'attempts'}
<input
id="check-{index}-{field}"
type="number"
bind:value={(check.config as any)[field]}
on:input={() => dispatchUpdate()}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
placeholder={field === 'port' ? '443' : field === 'timeout' ? '5000' : ''}
/>
{:else}
<div class="relative">
<input
id="check-{index}-{field}"
type="text"
bind:value={(check.config as any)[field]}
on:input={() => dispatchUpdate()}
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
placeholder={field === 'target' ? 'example.com or {{node-id}}' : field === 'domain' ? 'google.com' : ''}
/>
{#if (field === 'target' || field === 'domain') && nodeOptions.length > 0}
<NodeReferenceHelper
{nodeOptions}
{field}
on:insertReference={handleNodeReferenceInsert}
/>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,175 @@
<script lang="ts">
import { Plus, Trash2, Move, ChevronDown, ChevronRight } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import type { Layer, Check, NetworkNode } from '$lib/types';
import CheckEditor from './CheckEditor.svelte';
export let layer: Layer;
export let nodeOptions: NetworkNode[] = [];
export let expanded: boolean = false;
export let canMoveUp: boolean = false;
export let canMoveDown: boolean = false;
const dispatch = createEventDispatcher<{
layerUpdate: Layer;
toggleExpanded: void;
moveUp: void;
moveDown: void;
remove: void;
}>();
let expandedChecks: Set<string> = new Set();
function addCheck(): void {
const newCheck: Check = {
type: 'connectivityCheck',
config: {}
};
layer.checks = [...layer.checks, newCheck];
dispatchUpdate();
}
function removeCheck(checkIndex: number): void {
layer.checks = layer.checks.filter((_, i) => i !== checkIndex);
dispatchUpdate();
}
function handleCheckUpdate(event: CustomEvent<{ index: number; check: Check }>): void {
const { index: checkIndex, check } = event.detail;
layer.checks[checkIndex] = check;
layer.checks = layer.checks; // Trigger reactivity
dispatchUpdate();
}
function toggleCheck(checkId: string): void {
if (expandedChecks.has(checkId)) {
expandedChecks.delete(checkId);
} else {
expandedChecks.add(checkId);
}
expandedChecks = expandedChecks;
}
function dispatchUpdate(): void {
dispatch('layerUpdate', layer);
}
// Update layer name and description
function updateLayerName(event: Event): void {
const target = event.target as HTMLInputElement;
layer.name = target.value;
dispatchUpdate();
}
function updateLayerDescription(event: Event): void {
const target = event.target as HTMLTextAreaElement;
layer.description = target.value;
dispatchUpdate();
}
</script>
<div class="border border-gray-600 rounded-lg bg-gray-800/50">
<!-- Layer Header -->
<div class="flex items-center justify-between p-3 border-b border-gray-600">
<div class="flex items-center gap-3">
<button
type="button"
on:click={() => dispatch('toggleExpanded')}
class="text-gray-400 hover:text-white transition-colors"
>
{#if expanded}
<ChevronDown class="w-4 h-4" />
{:else}
<ChevronRight class="w-4 h-4" />
{/if}
</button>
<input
type="text"
value={layer.name}
on:input={updateLayerName}
class="bg-transparent text-white font-medium border-none outline-none focus:bg-gray-700 rounded px-2 py-1"
placeholder="Layer name"
/>
<span class="text-xs text-gray-400">
{layer.checks.length} check{layer.checks.length !== 1 ? 's' : ''}
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
on:click={() => dispatch('moveUp')}
disabled={!canMoveUp}
class="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Move class="w-3 h-3 rotate-180" />
</button>
<button
type="button"
on:click={() => dispatch('moveDown')}
disabled={!canMoveDown}
class="p-1 hover:bg-gray-700 rounded text-gray-400 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Move class="w-3 h-3" />
</button>
<button
type="button"
on:click={() => dispatch('remove')}
class="p-1 hover:bg-red-700 rounded text-gray-400 hover:text-red-400 transition-colors"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
</div>
<!-- Layer Content -->
{#if expanded}
<div class="p-3 space-y-3">
<!-- Layer Description -->
{#if layer.description !== undefined}
<div>
<label for="layer-description-{layer.id}" class="block text-xs font-medium text-gray-300 mb-1">
Description
</label>
<textarea
id="layer-description-{layer.id}"
value={layer.description}
on:input={updateLayerDescription}
rows="2"
class="w-full bg-gray-700 border border-gray-600 text-white rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500"
placeholder="Layer description..."
></textarea>
</div>
{/if}
<!-- Add Check Button -->
<button
type="button"
on:click={addCheck}
class="w-full flex items-center justify-center gap-2 py-2 border-2 border-dashed border-gray-600 hover:border-gray-500 text-gray-400 hover:text-gray-300 rounded-lg transition-colors"
>
<Plus class="w-4 h-4" />
Add Check
</button>
<!-- Checks -->
<div class="space-y-3">
{#each layer.checks as check, checkIndex}
<CheckEditor
{check}
index={checkIndex}
{nodeOptions}
expanded={expandedChecks.has(`${layer.id}-${checkIndex}`)}
on:checkUpdate={handleCheckUpdate}
on:toggleExpanded={() => toggleCheck(`${layer.id}-${checkIndex}`)}
on:remove={() => removeCheck(checkIndex)}
/>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import type { Layer, NetworkNode } from '$lib/types';
import LayerEditor from './LayerEditor.svelte';
export let layers: Layer[] = [];
export let nodeOptions: NetworkNode[] = [];
const dispatch = createEventDispatcher<{
layersChange: Layer[];
}>();
let expandedLayers: Set<string> = new Set(layers.map(l => l.id));
function addLayer(): void {
const newLayer: Layer = {
id: crypto.randomUUID(),
name: `Layer ${layers.length + 1}`,
description: '',
checks: [],
failureActions: []
};
layers = [...layers, newLayer];
expandedLayers.add(newLayer.id);
expandedLayers = expandedLayers;
dispatch('layersChange', layers);
}
function removeLayer(layerId: string): void {
layers = layers.filter(l => l.id !== layerId);
expandedLayers.delete(layerId);
expandedLayers = expandedLayers;
dispatch('layersChange', layers);
}
function moveLayer(index: number, direction: 'up' | 'down'): void {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= layers.length) return;
const newLayers = [...layers];
[newLayers[index], newLayers[newIndex]] = [newLayers[newIndex], newLayers[index]];
layers = newLayers;
dispatch('layersChange', layers);
}
function toggleLayer(layerId: string): void {
if (expandedLayers.has(layerId)) {
expandedLayers.delete(layerId);
} else {
expandedLayers.add(layerId);
}
expandedLayers = expandedLayers;
}
function handleLayerUpdate(event: CustomEvent<Layer>): void {
const updatedLayer = event.detail;
const index = layers.findIndex(l => l.id === updatedLayer.id);
if (index !== -1) {
layers[index] = updatedLayer;
layers = layers; // Trigger reactivity
dispatch('layersChange', layers);
}
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-white">Test Layers</h3>
<button
type="button"
on:click={addLayer}
class="flex items-center gap-2 px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm transition-colors"
>
<Plus class="w-4 h-4" />
Add Layer
</button>
</div>
{#each layers as layer, index}
<LayerEditor
{layer}
{nodeOptions}
expanded={expandedLayers.has(layer.id)}
canMoveUp={index > 0}
canMoveDown={index < layers.length - 1}
on:layerUpdate={handleLayerUpdate}
on:toggleExpanded={() => toggleLayer(layer.id)}
on:moveUp={() => moveLayer(index, 'up')}
on:moveDown={() => moveLayer(index, 'down')}
on:remove={() => removeLayer(layer.id)}
/>
{/each}
</div>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { NetworkNode } from '$lib/types';
export let nodeOptions: NetworkNode[] = [];
export let field: string;
const dispatch = createEventDispatcher<{
insertReference: { field: string; reference: string };
}>();
interface NodeReferenceOption {
value: string;
label: string;
}
function getNodeReferenceOptions(): NodeReferenceOption[] {
return nodeOptions.flatMap((node): NodeReferenceOption[] => [
{ value: `{{${node.id}}}`, label: `${node.name} (auto)` },
{ value: `{{${node.id}.domain}}`, label: `${node.name} (domain)` },
{ value: `{{${node.id}.ip}}`, label: `${node.name} (IP)` },
{ value: `{{${node.id}.defaultPort}}`, label: `${node.name} (port)` }
]);
}
function handleReferenceSelect(event: Event): void {
const target = event.target as HTMLSelectElement;
const reference = target.value;
if (reference) {
dispatch('insertReference', { field, reference });
target.value = ''; // Reset select
}
}
</script>
<select
on:change={handleReferenceSelect}
class="absolute right-0 top-0 h-full bg-gray-600 border-l border-gray-500 text-white text-xs px-2 rounded-r opacity-75 hover:opacity-100"
title="Insert node reference"
>
<option value="">Node ref...</option>
{#each getNodeReferenceOptions() as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import { Save, AlertCircle } from 'lucide-svelte';
import { testActions, validateTest, createBlankTest } from '../../../stores/tests';
import { nodes } from '../../../stores/nodes';
import { modalActions, notificationActions } from '../../../stores/ui';
import type { Test, Layer, Check } from '$lib/types';
import LayerManager from './LayerManager.svelte';
import ValidationDisplay from './ValidationDisplay.svelte';
export let mode: 'create' | 'edit' = 'create';
export let test: Test | null = null;
// Initialize form data
let formData: Test = {
name: '',
description: '',
version: '1.0',
layers: [],
...test
};
// Ensure layers have IDs and proper structure
if (formData.layers) {
formData.layers = formData.layers.map((layer): Layer => ({
...layer,
id: layer.id || crypto.randomUUID(),
description: layer.description || '',
checks: layer.checks || [],
failureActions: layer.failureActions || []
}));
}
// If creating and no layers, add a default layer
if (mode === 'create' && formData.layers.length === 0) {
const blank = createBlankTest();
formData.layers = blank.layers.map((layer): Layer => ({
id: crypto.randomUUID(),
name: layer.name || 'Layer 1',
description: layer.description || '',
checks: layer.checks.map((check): Check => ({
type: check.type,
config: check.config
})),
failureActions: layer.failureActions || []
}));
}
let errors: string[] = [];
let saving: boolean = false;
function validateForm(): boolean {
errors = validateTest(formData, $nodes);
return errors.length === 0;
}
async function handleSave(): Promise<void> {
if (!validateForm()) {
return;
}
saving = true;
try {
// Clean up form data
const cleanData: Test = {
...formData,
layers: formData.layers.map((layer): Layer => ({
...layer,
checks: layer.checks.filter(check => check.type), // Remove incomplete tests
}))
};
if (mode === 'create') {
await testActions.add(cleanData);
notificationActions.success(`Created test: ${cleanData.name}`);
} else {
if (!formData.id) {
throw new Error('Test ID is required for updates');
}
await testActions.update(formData.id, cleanData);
notificationActions.success(`Updated test: ${cleanData.name}`);
}
modalActions.close();
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
notificationActions.error(`Failed to save test: ${errorMessage}`);
} finally {
saving = false;
}
}
function handleCancel(): void {
modalActions.close();
}
function handleLayersChange(event: CustomEvent<Layer[]>): void {
formData.layers = event.detail;
formData = formData; // Trigger reactivity
}
</script>
<div class="p-6 max-h-[80vh] overflow-y-auto">
<form on:submit|preventDefault={handleSave} class="space-y-6">
<!-- Basic Info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="test-name" class="block text-sm font-medium text-gray-300 mb-2">
Test Name <span class="text-red-400">*</span>
</label>
<input
id="test-name"
type="text"
bind:value={formData.name}
required
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="e.g., Weekly Connectivity Check"
/>
</div>
<div>
<label for="test-version" class="block text-sm font-medium text-gray-300 mb-2">
Version
</label>
<input
id="test-version"
type="text"
bind:value={formData.version}
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="1.0"
/>
</div>
</div>
<div>
<label for="test-description" class="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
id="test-description"
bind:value={formData.description}
rows="2"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="Describe what this test accomplishes..."
></textarea>
</div>
<!-- Test Layers -->
<LayerManager
bind:layers={formData.layers}
nodeOptions={$nodes}
on:layersChange={handleLayersChange}
/>
<!-- Validation Errors -->
{#if errors.length > 0}
<ValidationDisplay {errors} />
{/if}
<!-- Form Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-gray-700">
<button
type="button"
on:click={handleCancel}
class="px-4 py-2 text-gray-300 hover:text-white transition-colors"
disabled={saving}
>
Cancel
</button>
<button
type="submit"
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
disabled={saving}
>
<Save class="w-4 h-4" />
{saving ? 'Saving...' : mode === 'create' ? 'Create Test' : 'Update Test'}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { AlertCircle } from 'lucide-svelte';
export let errors: string[] = [];
</script>
{#if errors.length > 0}
<div class="bg-red-900/30 border border-red-700/50 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<AlertCircle class="w-4 h-4 text-red-400" />
<span class="text-red-400 font-medium">Validation Errors</span>
</div>
<ul class="text-sm text-red-300 space-y-1">
{#each errors as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}

View File

@@ -0,0 +1,88 @@
<!-- src/lib/components/shared/Card.svelte -->
<script lang="ts">
export let title: string;
export let description: string = '';
export let metadata: Array<{label: string, value: string}> = [];
export let onEdit: (() => void) | null = null;
export let onCopy: (() => void) | null = null;
export let onDelete: (() => void) | null = null;
export let onRun: (() => void) | null = null; // Optional run action for tests
import { Edit, Copy, Trash2, Play } from 'lucide-svelte';
</script>
<div class="card-modern hover:bg-gray-700/50 transition-all duration-200 flex flex-col h-full">
<!-- Content area that grows to push buttons to bottom -->
<div class="flex-1">
<!-- Header with title and description -->
<div class="mb-4">
<h4 class="font-medium text-white mb-1">{title}</h4>
{#if description}
<p class="text-sm text-gray-400 line-clamp-2">{description}</p>
{/if}
</div>
<!-- Metadata -->
{#if metadata.length > 0}
<div class="space-y-2 text-sm">
{#each metadata as item}
<div class="flex justify-between">
<span class="text-gray-400">{item.label}:</span>
<span class="text-white font-mono">{item.value}</span>
</div>
{/each}
</div>
{/if}
</div>
<!-- Action buttons at bottom - full width with equal spacing -->
<div class="flex justify-between pt-4 border-t border-gray-700 mt-4">
{#if onRun}
<button
class="flex-1 mx-1 p-2 hover:bg-green-700 rounded text-gray-400 hover:text-green-400 transition-colors flex items-center justify-center"
on:click={onRun}
title="Run test"
>
<Play class="w-4 h-4" />
</button>
{/if}
{#if onEdit}
<button
class="flex-1 mx-1 p-2 hover:bg-gray-700 rounded text-gray-400 hover:text-white transition-colors flex items-center justify-center"
on:click={onEdit}
title="Edit"
>
<Edit class="w-4 h-4" />
</button>
{/if}
{#if onCopy}
<button
class="flex-1 mx-1 p-2 hover:bg-gray-700 rounded text-gray-400 hover:text-white transition-colors flex items-center justify-center"
on:click={onCopy}
title="Duplicate"
>
<Copy class="w-4 h-4" />
</button>
{/if}
{#if onDelete}
<button
class="flex-1 mx-1 p-2 hover:bg-red-700 rounded text-gray-400 hover:text-red-400 transition-colors flex items-center justify-center"
on:click={onDelete}
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
{/if}
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,534 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
Play,
Square,
RotateCcw,
CheckCircle,
XCircle,
Clock,
Zap,
AlertTriangle,
Info,
ChevronDown,
ChevronRight,
Download,
Share,
Server,
GitBranch
} from 'lucide-svelte';
import { commands } from '../../tauri-commands';
import { tests } from '../../stores/tests';
import { nodes } from '../../stores/nodes';
import { loadingActions, notificationActions } from '../../stores/ui';
import type { Test, DiagnosticResults, NetworkNode, CheckResult } from '../../types';
// Selected test for running diagnostics
let selectedTestId: string | null = null;
// Execution state
let isRunning: boolean = false;
let currentResults: DiagnosticResults | null = null;
let executionProgress = {
currentLayer: null as string | null,
currentCheck: null as string | null,
layersCompleted: 0,
checksCompleted: 0,
totalLayers: 0,
totalChecks: 0,
startTime: null as number | null,
elapsedTime: 0
};
// UI state
let expandedLayers: Set<string> = new Set<string>();
let expandedChecks: Set<string> = new Set<string>();
let autoScroll: boolean = true;
let progressTimer: number | null = null;
// Reactive computations
$: selectedTest = selectedTestId ? $tests.find(t => t.id === selectedTestId) : null;
$: hasSelectedTest = selectedTest !== null;
$: canRun = hasSelectedTest && !isRunning;
$: interpolatedTest = selectedTest ? interpolateTest(selectedTest, $nodes) : null;
$: overallSuccess = currentResults ? currentResults.success : null;
$: progressPercentage = executionProgress.totalLayers > 0
? Math.round((executionProgress.layersCompleted / executionProgress.totalLayers) * 100)
: 0;
// Auto-select first test if none selected
$: if (!selectedTestId && $tests.length > 0) {
const firstTestId = $tests[0]?.id;
if (firstTestId) {
selectedTestId = firstTestId;
}
}
// Helper function to interpolate node references in test
function interpolateTest(test: Test, availableNodes: NetworkNode[]): Test {
const nodeMap = new Map(availableNodes.map(node => [node.id, node]));
function interpolateValue(value: any): any {
if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
const match = value.match(/^\{\{([^.}]+)(?:\.([^}]+))?\}\}$/);
if (match) {
const [, nodeId, field] = match;
const node = nodeMap.get(nodeId);
if (node) {
switch (field) {
case 'domain': return node.domain || '';
case 'ip': return node.ip || '';
case 'defaultPort': return node.defaultPort?.toString() || '';
case 'path': return node.path || '';
default: return node.domain || node.ip || '';
}
}
}
return value; // Return as-is if can't interpolate
}
if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
return value.map(interpolateValue);
} else {
const result: any = {};
for (const [key, val] of Object.entries(value)) {
result[key] = interpolateValue(val);
}
return result;
}
}
return value;
}
return interpolateValue(test) as Test;
}
onMount(async () => {
// Load any previous results
try {
const results = await commands.getDiagnosticResults();
if (results) {
currentResults = results;
expandAllOnResults();
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.warn('No previous diagnostic results found:', errorMessage);
}
});
onDestroy(() => {
if (progressTimer) {
clearInterval(progressTimer);
}
});
function expandAllOnResults(): void {
if (currentResults) {
currentResults.layers.forEach(layer => {
expandedLayers.add(layer.id);
layer.checks.forEach((_, checkIndex) => {
expandedChecks.add(`${layer.id}-${checkIndex}`);
});
});
expandedLayers = expandedLayers;
expandedChecks = expandedChecks;
}
}
function toggleLayer(layerId: string): void {
if (expandedLayers.has(layerId)) {
expandedLayers.delete(layerId);
// Also collapse all tests in this layer
if (currentResults) {
const layer = currentResults.layers.find(l => l.id === layerId);
if (layer) {
layer.checks.forEach((_, checkIndex) => {
expandedChecks.delete(`${layerId}-${checkIndex}`);
});
}
}
} else {
expandedLayers.add(layerId);
}
expandedLayers = expandedLayers;
expandedChecks = expandedChecks;
}
function toggleCheck(layerId: string, checkIndex: number): void {
const checkId = `${layerId}-${checkIndex}`;
if (expandedChecks.has(checkId)) {
expandedChecks.delete(checkId);
} else {
expandedChecks.add(checkId);
}
expandedChecks = expandedChecks;
}
async function runDiagnostics(): Promise<void> {
if (!interpolatedTest || isRunning) return;
isRunning = true;
currentResults = null;
loadingActions.setDiagnostics(true);
// Initialize progress tracking
executionProgress = {
currentLayer: null,
currentCheck: null,
layersCompleted: 0,
checksCompleted: 0,
totalLayers: interpolatedTest.layers.length,
totalChecks: interpolatedTest.layers.reduce((sum, layer) => sum + layer.checks.length, 0),
startTime: Date.now(),
elapsedTime: 0
};
// Start progress timer
progressTimer = setInterval(() => {
if (executionProgress.startTime) {
executionProgress.elapsedTime = Date.now() - executionProgress.startTime;
}
}, 100);
try {
notificationActions.info(`Starting diagnostics for ${selectedTest?.name}...`);
const results = await commands.runDiagnostics(interpolatedTest);
currentResults = results;
expandAllOnResults();
if (results.success) {
notificationActions.success(`Diagnostics completed successfully in ${formatDuration(results.totalDuration)}`);
} else {
notificationActions.warning(`Diagnostics completed with failures in ${formatDuration(results.totalDuration)}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Diagnostics failed:', error);
notificationActions.error(`Diagnostics failed: ${errorMessage}`);
} finally {
isRunning = false;
loadingActions.setDiagnostics(false);
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
}
}
function stopDiagnostics(): void {
isRunning = false;
loadingActions.setDiagnostics(false);
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
notificationActions.info('Diagnostics stopped by user');
}
function clearResults(): void {
currentResults = null;
expandedLayers.clear();
expandedChecks.clear();
expandedLayers = expandedLayers;
expandedChecks = expandedChecks;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function getStatusColor(success: boolean): string {
return success ? 'text-green-400' : 'text-red-400';
}
function getStatusIcon(success: boolean) {
return success ? CheckCircle : XCircle;
}
function exportResults(): void {
if (!currentResults) return;
const filename = `diagnostic-results-${new Date().toISOString().split('T')[0]}.json`;
commands.exportData('diagnostics', currentResults, filename)
.then(() => {
notificationActions.success('Results exported successfully');
})
.catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
notificationActions.error(`Export failed: ${errorMessage}`);
});
}
</script>
<div class="space-y-6">
<!-- Header with Test Selector -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-white">Diagnostics</h2>
<p class="text-gray-400 mt-1">Run network tests</p>
</div>
</div>
<!-- Test Selector and Controls -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Select Test Configuration</h3>
<!-- Action Buttons -->
<div class="flex items-center gap-3">
{#if currentResults}
<button
on:click={exportResults}
class="flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<Download class="w-4 h-4" />
Export
</button>
<button
on:click={clearResults}
class="flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<RotateCcw class="w-4 h-4" />
Clear
</button>
{/if}
{#if isRunning}
<button
on:click={stopDiagnostics}
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
<Square class="w-4 h-4" />
Stop
</button>
{:else}
<button
on:click={runDiagnostics}
disabled={!canRun}
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:opacity-50 text-white rounded-lg transition-colors"
>
<Play class="w-4 h-4" />
Run
</button>
{/if}
</div>
</div>
<!-- Test Selector -->
{#if $tests.length > 0}
<div class="space-y-4">
<div>
<label for="test-selector" class="block text-sm font-medium text-gray-300 mb-2">
Test
</label>
<select
id="test-selector"
bind:value={selectedTestId}
disabled={isRunning}
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
>
{#each $tests as test}
<option value={test.id}>
{test.name} ({test.layers?.length || 0} layers, {test.layers?.reduce((sum, layer) => sum + (layer.checks?.length || 0), 0) || 0} tests)
</option>
{/each}
</select>
</div>
<!-- Selected Test Preview -->
{#if selectedTest}
<div class="bg-gray-700/50 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="p-2 bg-purple-600/20 border border-purple-600/30 rounded-lg">
<GitBranch class="w-5 h-5 text-purple-400" />
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-white mb-1">{selectedTest.name}</h4>
{#if selectedTest.description}
<p class="text-gray-400 text-sm mb-2">{selectedTest.description}</p>
{/if}
<div class="flex items-center gap-4 text-sm text-gray-300">
<span>Version: {selectedTest.version || '1.0'}</span>
<span></span>
<span>{selectedTest.layers?.length || 0} layers</span>
<span></span>
<span>{selectedTest.layers?.reduce((sum, layer) => sum + (layer.checks?.length || 0), 0) || 0} total tests</span>
</div>
</div>
</div>
</div>
{/if}
</div>
{:else}
<div class="text-center py-8">
<GitBranch class="w-12 h-12 mx-auto text-gray-600 mb-3" />
<h4 class="text-lg font-semibold text-gray-300 mb-2">No Tests</h4>
<p class="text-gray-400 mb-4">Create a test in the Tests tab to run diagnostics.</p>
</div>
{/if}
</div>
<!-- Progress Indicator -->
{#if isRunning}
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<div class="flex items-center gap-4 mb-4">
<div class="animate-spin">
<Clock class="w-5 h-5 text-blue-400" />
</div>
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<span class="text-white font-medium">Running Diagnostics...</span>
<span class="text-gray-400 text-sm">{progressPercentage}%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: {progressPercentage}%"></div>
</div>
</div>
</div>
<div class="text-sm text-gray-400">
{executionProgress.layersCompleted} of {executionProgress.totalLayers} layers completed
{Math.round(executionProgress.elapsedTime / 1000)}s elapsed
</div>
</div>
{/if}
<!-- Results Display -->
{#if currentResults}
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
<!-- Results Header -->
<div class="p-6 border-b border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
{#if overallSuccess !== null}
<svelte:component this={getStatusIcon(overallSuccess)} class="w-5 h-5 {getStatusColor(overallSuccess)}" />
<h3 class="text-lg font-semibold text-white">
{overallSuccess ? 'All Tests Passed' : 'Tests Failed'}
</h3>
{:else}
<h3 class="text-lg font-semibold text-white">Test Results</h3>
{/if}
</div>
{#if overallSuccess !== null}
<span class="px-2 py-1 rounded text-xs font-medium {overallSuccess ? 'bg-green-600/20 text-green-400' : 'bg-red-600/20 text-red-400'}">
{overallSuccess ? 'SUCCESS' : 'FAILURE'}
</span>
{/if}
</div>
<div class="text-sm text-gray-400">
Completed in {formatDuration(currentResults.totalDuration)}
</div>
</div>
<div class="mt-4 text-sm text-gray-300">
<span class="font-medium">{currentResults.test}</span>
<span class="mx-2"></span>
<span>{new Date(currentResults.timestamp).toLocaleString()}</span>
<span class="mx-2"></span>
<span>{currentResults.layers.length} layers tested</span>
</div>
</div>
<!-- Layer Results -->
<div class="divide-y divide-gray-700">
{#each currentResults.layers as layer, layerIndex}
<div class="p-4">
<!-- Layer Header -->
<button
class="w-full flex items-center justify-between p-2 hover:bg-gray-700/50 rounded-lg transition-colors"
on:click={() => toggleLayer(layer.id)}
>
<div class="flex items-center gap-3">
<svelte:component this={expandedLayers.has(layer.id) ? ChevronDown : ChevronRight} class="w-4 h-4 text-gray-400" />
<svelte:component this={getStatusIcon(layer.success)} class="w-5 h-5 {getStatusColor(layer.success)}" />
<div class="text-left">
<h4 class="font-medium text-white">{layer.name}</h4>
<p class="text-sm text-gray-400">{layer.description}</p>
</div>
</div>
<div class="text-right text-sm text-gray-400">
<div>{formatDuration(layer.duration)}</div>
<div>{layer.checks.filter(t => t.success).length}/{layer.checks.length} passed</div>
</div>
</button>
<!-- Layer Details -->
{#if expandedLayers.has(layer.id)}
<div class="mt-4 ml-6 space-y-3">
<!-- Test Results -->
{#each layer.checks as check, checkIndex}
<div class="border border-gray-600 rounded-lg overflow-hidden">
<button
class="w-full flex items-center justify-between p-3 hover:bg-gray-700/30 transition-colors"
on:click={() => toggleCheck(layer.id, checkIndex)}
>
<div class="flex items-center gap-3">
<svelte:component this={expandedChecks.has(`${layer.id}-${checkIndex}`) ? ChevronDown : ChevronRight} class="w-3 h-3 text-gray-400" />
<svelte:component this={getStatusIcon(check.success)} class="w-4 h-4 {getStatusColor(check.success)}" />
<div class="text-left">
<span class="font-medium text-white">{check.type.replace('_', ' ').toUpperCase()}</span>
{#if check.config.target}
<span class="text-gray-400 ml-2">{check.config.target}</span>
{/if}
</div>
</div>
<div class="text-right text-sm">
<div class="{getStatusColor(check.success)}">{check.success ? 'PASS' : 'FAIL'}</div>
<div class="text-gray-400">{formatDuration(check.duration)}</div>
</div>
</button>
<!-- Check Details -->
{#if expandedChecks.has(`${layer.id}-${checkIndex}`)}
<div class="p-3 bg-gray-700/30 border-t border-gray-600">
<div class="space-y-2 text-sm">
<div>
<span class="text-gray-400">Message:</span>
<span class="text-white ml-2">{check.message}</span>
</div>
{#if check.error}
<div>
<span class="text-gray-400">Error:</span>
<span class="text-red-400 ml-2">{check.error}</span>
</div>
{/if}
{#if check.details}
<div>
<span class="text-gray-400">Details:</span>
<pre class="text-xs text-gray-300 mt-1 bg-gray-800 p-2 rounded overflow-x-auto">{JSON.stringify(check.details, null, 2)}</pre>
</div>
{/if}
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Empty State -->
{#if hasSelectedTest && !currentResults && !isRunning}
<div class="text-center py-12">
<Zap class="w-16 h-16 mx-auto text-gray-600 mb-4" />
<h3 class="text-xl font-semibold text-gray-300 mb-2">Ready to Run Diagnostics</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">
Click "Run Diagnostics" to test your network configuration with the selected test.
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,101 @@
<!-- Update src/lib/components/tabs/NodesTab.svelte -->
<script lang="ts">
import { Plus, Server, FolderOpen, Folder } from 'lucide-svelte';
import { nodes, nodeActions } from '../../stores/nodes';
import { modalActions, notificationActions } from '../../stores/ui';
import NodeEditor from '../modals/NodeEditor.svelte';
import ConfirmDialog from '../modals/ConfirmDialog.svelte';
import Card from '../shared/Card.svelte';
function createNode() {
modalActions.open(NodeEditor, { mode: 'create' }, 'Create Network Node');
}
function editNode(node: any) {
modalActions.open(NodeEditor, { node }, 'Edit Network Node');
modalActions.open(NodeEditor, {
mode: 'edit',
node: { ...node }
}, `Edit ${node.name}`);
}
async function duplicateNode(nodeId: string) {
try {
await nodeActions.duplicate(nodeId);
} catch (error) {
console.error('Failed to duplicate node:', error);
}
}
async function deleteNode(node: any) {
modalActions.open(ConfirmDialog, {
title: 'Delete Node',
message: `Are you sure you want to delete "${node.name}"? This action cannot be undone.`,
confirmText: 'Delete',
cancelText: 'Cancel',
danger: true,
onConfirm: async () => {
try {
await nodeActions.delete(node.id);
notificationActions.success(`Deleted test: ${node.name}`);
} catch (error) {
notificationActions.error('Failed to delete test');
console.error('Failed to delete test:', error);
}
}
}, 'Confirm Deletion');
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-white">Network Nodes</h2>
<p class="text-gray-400 mt-1">Manage network endpoints and services</p>
</div>
<button
on:click={createNode}
class="btn-primary flex items-center gap-2"
>
<Plus class="w-4 h-4" />
Add Node
</button>
</div>
<!-- Nodes organized by folder -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each $nodes as node}
<Card
title={node.name}
metadata={[
...(node.domain ? [{ label: 'Domain', value: node.domain }] : []),
...(node.ip ? [{ label: 'IP', value: node.ip }] : []),
...(node.defaultPort ? [{ label: 'Port', value: node.defaultPort.toString() }] : [])
]}
description={node.description || ''}
onEdit={() => editNode(node)}
onCopy={() => duplicateNode(node.id)}
onDelete={() => deleteNode(node)}
/>
{/each}
</div>
<!-- Empty state -->
{#if $nodes.length === 0}
<div class="text-center py-12">
<Server class="w-16 h-16 mx-auto text-gray-600 mb-4" />
<h3 class="text-xl font-semibold text-gray-300 mb-2">No Network Nodes</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">
Create your first network node
</p>
<button
on:click={createNode}
class="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors mx-auto"
>
<Plus class="w-4 h-4" />
Create Network Node
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,138 @@
<!-- Update src/lib/components/tabs/TestsTab.svelte -->
<script lang="ts">
import { Plus, GitBranch } from 'lucide-svelte';
import { tests, testActions } from '../../stores/tests';
import { modalActions, activeTab, notificationActions } from '../../stores/ui';
import TestEditor from '../modals/test_editor/TestEditor.svelte';
import ConfirmDialog from '../modals/ConfirmDialog.svelte';
import Card from '../shared/Card.svelte';
import type { Test } from '$lib/types';
function createTest(): void {
modalActions.open(TestEditor, { mode: 'create' }, 'Create Test');
}
function editTest(test: Test): void {
modalActions.open(TestEditor, {
mode: 'edit',
test: { ...test }
}, `Edit ${test.name}`);
}
async function duplicateTest(testId: string): Promise<void> {
try {
await testActions.duplicate(testId);
notificationActions.success('Test duplicated successfully');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
notificationActions.error('Failed to duplicate test');
console.error('Failed to duplicate test:', errorMessage);
}
}
function deleteTest(test: Test): void {
if (!test.id) {
notificationActions.error('Cannot delete test: missing ID');
return;
}
modalActions.open(ConfirmDialog, {
title: 'Delete Test',
message: `Are you sure you want to delete "${test.name}"? This action cannot be undone.`,
confirmText: 'Delete',
cancelText: 'Cancel',
danger: true,
onConfirm: async () => {
try {
if (!test.id) {
throw new Error('Test ID is missing');
}
await testActions.delete(test.id);
notificationActions.success(`Deleted test: ${test.name}`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
notificationActions.error('Failed to delete test');
console.error('Failed to delete test:', errorMessage);
}
}
}, 'Confirm Deletion');
}
function runTest(testId: string): void {
// Navigate to diagnostics tab and run this specific test
activeTab.set('diagnostics');
}
function handleDuplicateTest(test: Test): void {
if (!test.id) {
notificationActions.error('Cannot duplicate test: missing ID');
return;
}
duplicateTest(test.id);
}
function handleRunTest(test: Test): void {
if (!test.id) {
notificationActions.error('Cannot run test: missing ID');
return;
}
runTest(test.id);
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-white">Tests</h2>
<p class="text-gray-400 mt-1">Configure network tests</p>
</div>
<button
on:click={createTest}
class="btn-primary flex items-center gap-2"
>
<Plus class="w-4 h-4" />
Create Test
</button>
</div>
<!-- Tests Grid -->
{#if $tests.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each $tests as test}
{@const layerCount = test.layers?.length || 0}
{@const totalChecks = test.layers?.reduce((sum, layer) => sum + (layer.checks?.length || 0), 0) || 0}
<Card
title={test.name}
description={test.description || ''}
metadata={[
{ label: 'Layers', value: layerCount.toString() },
{ label: 'Tests', value: totalChecks.toString() },
{ label: 'Version', value: test.version || 'v1.0' }
]}
onEdit={() => editTest(test)}
onCopy={() => handleDuplicateTest(test)}
onDelete={() => deleteTest(test)}
onRun={() => handleRunTest(test)}
/>
{/each}
</div>
{:else}
<!-- Empty state -->
<div class="text-center py-12">
<GitBranch class="w-16 h-16 mx-auto text-gray-600 mb-4" />
<h3 class="text-xl font-semibold text-gray-300 mb-2">No Tests</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">
Create your first network test
</p>
<button
on:click={createTest}
class="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors mx-auto"
>
<Plus class="w-4 h-4" />
Create Test
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { Plus, Edit, Copy, Trash2, Download, Share, Upload, GitBranch } from 'lucide-svelte';
import { topologies, topologyActions, createBlankTopology } from '../../stores/topologies';
import { modalActions, notificationActions } from '../../stores/ui';
import TopologyEditor from '../modals/TopologyEditor.svelte';
import ConfirmDialog from '../modals/ConfirmDialog.svelte';
import type { Topology } from '../../types';
function createTopology() {
const blankTopology = createBlankTopology();
modalActions.open(TopologyEditor, {
mode: 'create',
topology: blankTopology
}, 'Create New Topology');
}
function editTopology(topology: Topology) {
modalActions.open(TopologyEditor, {
mode: 'edit',
topology: { ...topology }
}, `Edit ${topology.name}`);
}
async function duplicateTopology(topology: Topology) {
try {
const duplicated = await topologyActions.duplicate(topology.id!);
notificationActions.success(`Duplicated topology: ${topology.name}`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
notificationActions.error(`Failed to duplicate topology: ${errorMessage}`);
}
}
function deleteTopology(topology: Topology) {
modalActions.open(ConfirmDialog, {
title: 'Delete Topology',
message: `Are you sure you want to delete "${topology.name}"? This action cannot be undone.`,
confirmText: 'Delete',
cancelText: 'Cancel',
danger: true,
onConfirm: async () => {
try {
await topologyActions.delete(topology.id!);
notificationActions.success(`Deleted topology: ${topology.name}`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
notificationActions.error(`Failed to delete topology: ${errorMessage}`);
}
}
}, 'Confirm Deletion');
}
function exportTopology(topology: Topology) {
const data = JSON.stringify(topology, null, 2);
const filename = `${topology.name.toLowerCase().replace(/\s+/g, '-')}-topology.json`;
// Create download link
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
notificationActions.success(`Exported topology: ${topology.name}`);
}
function importTopology() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = async (e: ProgressEvent<FileReader>) => {
try {
const result = e.target?.result;
if (typeof result === 'string') {
const topology = JSON.parse(result) as Topology;
// Remove ID to create as new topology
delete topology.id;
delete topology.createdAt;
delete topology.updatedAt;
await topologyActions.add(topology);
notificationActions.success(`Imported topology: ${topology.name}`);
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
notificationActions.error(`Failed to import topology: ${errorMessage}`);
}
};
reader.readAsText(file);
}
};
input.click();
}
function getTopologyStats(topology: Topology) {
const layerCount = topology.layers?.length || 0;
const testCount = topology.layers?.reduce((sum, layer) => sum + (layer.tests?.length || 0), 0) || 0;
return { layerCount, testCount };
}
</script>
<div class="space-y-6">
<!-- Header with actions -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-white">Test Topologies</h2>
<p class="text-gray-400 mt-1">Manage your network testing configurations</p>
</div>
<div class="flex gap-2">
<button
class="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
on:click={importTopology}
>
<Upload class="w-4 h-4" />
Import
</button>
<button
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
on:click={createTopology}
>
<Plus class="w-4 h-4" />
Create Topology
</button>
</div>
</div>
<!-- Topologies Grid -->
{#if $topologies.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each $topologies as topology}
{@const stats = getTopologyStats(topology)}
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 hover:border-gray-600 transition-colors">
<!-- Topology header -->
<div class="flex items-start gap-3 mb-4">
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-white text-lg mb-1 truncate">
{topology.name}
</h3>
{#if topology.description}
<p class="text-gray-400 text-sm line-clamp-2">
{topology.description}
</p>
{/if}
</div>
</div>
<!-- Stats -->
<div class="flex items-center gap-4 mb-4 text-sm text-gray-300">
<span>{stats.layerCount} layers</span>
<span></span>
<span>{stats.testCount} tests</span>
<span></span>
<span>v{topology.version || '1.0'}</span>
</div>
<!-- Actions -->
<div class="flex items-center justify-between">
<!-- Primary actions -->
<div class="flex gap-1">
<button
class="p-2 hover:bg-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors"
on:click={() => editTopology(topology)}
title="Edit topology"
>
<Edit class="w-4 h-4" />
</button>
<button
class="p-2 hover:bg-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors"
on:click={() => duplicateTopology(topology)}
title="Duplicate topology"
>
<Copy class="w-4 h-4" />
</button>
<button
class="p-2 hover:bg-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors"
on:click={() => exportTopology(topology)}
title="Export topology"
>
<Download class="w-4 h-4" />
</button>
</div>
<!-- Danger zone -->
<button
class="p-2 hover:bg-red-700 rounded-lg text-gray-400 hover:text-red-400 transition-colors"
on:click={() => deleteTopology(topology)}
title="Delete topology"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<!-- Empty State -->
<div class="text-center py-12">
<GitBranch class="w-16 h-16 mx-auto text-gray-600 mb-4" />
<h3 class="text-xl font-semibold text-gray-300 mb-2">No Test Topologies</h3>
<p class="text-gray-400 mb-6 max-w-md mx-auto">
Create your first topology to define network diagnostic tests and organize them into logical layers.
</p>
<button
on:click={createTopology}
class="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors mx-auto"
>
<Plus class="w-4 h-4" />
Create Topology
</button>
</div>
{/if}
</div>

354
src/lib/stores/checks.ts Normal file
View File

@@ -0,0 +1,354 @@
// src/lib/stores/checks.ts (Enhanced with category updates and detailed descriptions)
import type { CheckTypeConfig, CheckConfig } from '$lib/types';
import {
Wifi, Search, Shield, Router, Mail, Lock,
Server, Activity, Target, Globe, Zap
} from 'lucide-svelte';
// Category icons mapping for UI display
export const CATEGORY_ICONS: Record<string, any> = {
"Basic": Wifi,
"DNS": Search,
"VPN": Shield,
"Local Network": Router,
"Email": Mail,
"Security": Lock,
"Services": Server,
"Performance": Activity,
"Analysis": Target,
"CDN": Globe
};
// Helper function to get default check configuration
export function getDefaultCheckConfig(checkType: string): CheckConfig {
const checkTypeConfig = CHECK_TYPES[checkType];
if (checkTypeConfig && checkTypeConfig.defaults) {
return { ...checkTypeConfig.defaults };
}
return { timeout: 5000 };
}
// Check types available with improved categories and descriptions
export const CHECK_TYPES: Record<string, CheckTypeConfig> = {
// ================== BASIC CONNECTIVITY ==================
connectivityCheck: {
name: 'Connectivity Check',
description: 'Check HTTP/HTTPS connection to domain',
details: 'Performs a full HTTP/HTTPS request to a domain name, checking both DNS resolution and web server connectivity. Validates that the target responds to standard web requests.',
fields: ['target', 'port', 'protocol', 'timeout'],
defaults: { target: '', port: 443, protocol: 'https', timeout: 5000 },
category: "Basic"
},
directIpCheck: {
name: 'Direct IP Connection',
description: 'Check connection to IP address (no DNS)',
details: 'Connects directly to an IP address, bypassing DNS resolution entirely. Useful for checking raw network connectivity or diagnosing DNS-related issues.',
fields: ['target', 'port', 'timeout'],
defaults: { target: '', port: 443, timeout: 5000 },
category: "Basic"
},
serviceHealthCheck: {
name: 'Service Health',
description: 'Check web service returns expected status',
details: 'Makes an HTTP request to a specific path and validates the response status code. Perfect for monitoring API endpoints, health checks, and service availability.',
fields: ['target', 'port', 'path', 'expected_status', 'timeout'],
defaults: { target: '', port: 80, path: '/', expected_status: 200, timeout: 5000 },
category: "Basic"
},
responseTimeCheck: {
name: 'Response Time',
description: 'Measure connection latency vs threshold',
details: 'Measures the time from connection initiation to first response, ensuring it meets performance requirements. Helps identify slow network paths or overloaded servers.',
fields: ['target', 'port', 'timeout', 'max_response_time'],
defaults: { target: '', port: 443, timeout: 5000, max_response_time: 1000 },
category: "Basic"
},
pingCheck: {
name: 'Ping',
description: 'Check TCP connectivity with retry logic',
details: 'Performs multiple TCP connection attempts to measure success rate and connection reliability. Unlike ICMP ping, this checks actual service connectivity.',
fields: ['target', 'port', 'attempts', 'timeout'],
defaults: { target: '', port: 443, attempts: 3, timeout: 5000 },
category: "Basic"
},
wellknownIpCheck: {
name: 'Internet Backbone Check',
description: 'Check basic internet connectivity',
details: 'Connects to well-known public DNS servers (Google 8.8.8.8, Cloudflare 1.1.1.1) to verify fundamental internet connectivity without relying on DNS resolution.',
fields: ['timeout'],
defaults: { timeout: 3000 },
category: "Basic"
},
// ================== DNS RESOLUTION ==================
dnsResolutionCheck: {
name: 'DNS Resolution',
description: 'Check domain name to IP resolution',
details: 'Queries DNS servers to resolve domain names to IP addresses. Checks the fundamental DNS lookup process that underlies all internet communication.',
fields: ['domain', 'timeout'],
defaults: { domain: '', timeout: 5000 },
category: "DNS"
},
dnsOverHttpsCheck: {
name: 'DNS over HTTPS',
description: 'Check secure DNS queries via HTTPS',
details: 'Performs DNS lookups using DNS-over-HTTPS (DoH) for enhanced privacy and security. Checks connectivity to secure DNS providers like Cloudflare, Pi-hole, or custom DoH endpoints.',
fields: ['target', 'domain', 'service_type', 'timeout'],
defaults: { target: 'https://1.1.1.1/dns-query', domain: 'example.com', service_type: 'cloudflare', timeout: 5000 },
category: "DNS"
},
// ================== VPN CONNECTIVITY ==================
vpnConnectivityCheck: {
name: 'VPN Connectivity',
description: 'Check VPN connection is active',
details: 'Verifies that VPN connection is properly established and routing traffic through the VPN tunnel. Detects VPN disconnections and routing issues.',
fields: ['target', 'timeout'],
defaults: { target: '', timeout: 10000 },
category: "VPN"
},
vpnTunnelCheck: {
name: 'VPN Tunnel Integrity',
description: 'Check VPN tunnel stability',
details: 'Performs comprehensive checks to ensure VPN tunnel maintains integrity under load, including data transmission verification and connection stability over time.',
fields: ['target', 'timeout'],
defaults: { target: '', timeout: 15000 },
category: "VPN"
},
// ================== LOCAL NETWORK ==================
localGatewayCheck: {
name: 'Local Gateway Check',
description: 'Check connection to network gateway',
details: 'Checks connectivity to the local network gateway (router). Essential for diagnosing local network issues and verifying the first hop in network communication.',
fields: ['timeout'],
defaults: { timeout: 3000 },
category: "Local Network"
},
dhcpDiscoveryCheck: {
name: 'DHCP Discovery',
description: 'Discover DHCP servers on network',
details: 'Scans the local network segment to identify active DHCP servers. Useful for network troubleshooting and detecting rogue DHCP servers that could cause IP conflicts.',
fields: ['interface', 'timeout'],
defaults: { interface: 'auto', timeout: 10000 },
category: "Local Network"
},
subnetScanCheck: {
name: 'Subnet Scan',
description: 'Scan local network for active devices',
details: 'Performs a comprehensive scan of the local subnet to discover active devices and services. Maps the local network topology and identifies available resources.',
fields: ['subnet', 'concurrent_scans', 'timeout'],
defaults: { subnet: 'auto', concurrent_scans: 50, timeout: 30000 },
category: "Local Network"
},
// ================== EMAIL SERVICES ==================
smtpCheck: {
name: 'SMTP Server Check',
description: 'Test outbound email server connectivity',
details: 'Tests connection to SMTP (Simple Mail Transfer Protocol) servers used for sending email. Verifies authentication, TLS encryption, and basic SMTP command responses.',
fields: ['target', 'port', 'use_tls', 'timeout'],
defaults: { target: '', port: 587, use_tls: true, timeout: 10000 },
category: "Email"
},
imapCheck: {
name: 'IMAP Server Check',
description: 'Test inbound email server connectivity',
details: 'Tests connection to IMAP (Internet Message Access Protocol) servers for retrieving email. Validates secure connections and server responsiveness for email clients.',
fields: ['target', 'port', 'use_ssl', 'timeout'],
defaults: { target: '', port: 993, use_ssl: true, timeout: 10000 },
category: "Email"
},
pop3Check: {
name: 'POP3 Server Check',
description: 'Test POP3 email server connectivity',
details: 'Tests connection to POP3 (Post Office Protocol 3) servers for downloading email. Verifies server availability and secure connection establishment.',
fields: ['target', 'port', 'use_ssl', 'timeout'],
defaults: { target: '', port: 995, use_ssl: true, timeout: 10000 },
category: "Email"
},
// ================== SECURITY TESTING ==================
sslCertificateCheck: {
name: 'SSL Certificate Check',
description: 'Validate SSL/TLS certificates',
details: 'Examines SSL/TLS certificates for validity, expiration dates, and certificate chain integrity. Critical for ensuring secure connections and preventing certificate-related outages.',
fields: ['target', 'port', 'min_days_until_expiry', 'check_chain', 'timeout'],
defaults: { target: '', port: 443, min_days_until_expiry: 30, check_chain: true, timeout: 10000 },
category: "Security"
},
portScanCheck: {
name: 'Port Scan Check',
description: 'Scan for open ports and services',
details: 'Systematically tests specified ports to identify open services and potential security vulnerabilities. Essential for security audits and network reconnaissance.',
fields: ['target', 'port_range', 'scan_type', 'timeout'],
defaults: { target: '', port_range: '80,443,22,21,25,53,3389,5432,3306', scan_type: 'tcp', timeout: 2000 },
category: "Security"
},
// ================== SERVICES ==================
ftpCheck: {
name: 'FTP Server Check',
description: 'Check FTP/FTPS server connectivity',
details: 'Checks connection to FTP (File Transfer Protocol) servers with support for both standard FTP and secure FTPS. Validates server availability and protocol handshake.',
fields: ['target', 'port', 'use_ssl', 'passive_mode', 'timeout'],
defaults: { target: '', port: 21, use_ssl: false, passive_mode: true, timeout: 10000 },
category: "Services"
},
sshCheck: {
name: 'SSH Server Check',
description: 'Check SSH server connectivity',
details: 'Checks connection to SSH (Secure Shell) servers for remote access. Validates server availability, protocol version, and secure connection establishment without authentication.',
fields: ['target', 'port', 'check_banner', 'timeout'],
defaults: { target: '', port: 22, check_banner: true, timeout: 10000 },
category: "Services"
},
databaseCheck: {
name: 'Database Server Check',
description: 'Check database server connectivity',
details: 'Checks connectivity to database servers including MySQL, PostgreSQL, MongoDB, and others. Validates that database services are accepting connections on their standard ports.',
fields: ['target', 'port', 'db_type', 'timeout'],
defaults: { target: '', port: 3306, db_type: 'mysql', timeout: 10000 },
category: "Services"
},
ntpCheck: {
name: 'NTP Time Server Check',
description: 'Check network time synchronization',
details: 'Checks connectivity to NTP (Network Time Protocol) servers and validates time synchronization accuracy. Critical for systems requiring precise time coordination.',
fields: ['target', 'port', 'max_time_drift', 'timeout'],
defaults: { target: 'pool.ntp.org', port: 123, max_time_drift: 1000, timeout: 5000 },
category: "Services"
},
ldapCheck: {
name: 'LDAP Server Check',
description: 'Check directory service connectivity',
details: 'Checks connection to LDAP (Lightweight Directory Access Protocol) servers used for directory services and authentication. Supports both standard LDAP and secure LDAPS.',
fields: ['target', 'port', 'use_ssl', 'bind_dn', 'timeout'],
defaults: { target: '', port: 389, use_ssl: false, bind_dn: '', timeout: 10000 },
category: "Services"
},
sipCheck: {
name: 'SIP Server Check',
description: 'Check VoIP server connectivity',
details: 'Checks connection to SIP (Session Initiation Protocol) servers used for VoIP communications. Validates server availability for voice and video calling services.',
fields: ['target', 'port', 'transport', 'timeout'],
defaults: { target: '', port: 5060, transport: 'udp', timeout: 5000 },
category: "Services"
},
// ================== PERFORMANCE TESTING ==================
bandwidthCheck: {
name: 'Bandwidth Check',
description: 'Measure network throughput',
details: 'Performs download and upload tests to measure actual network bandwidth and throughput. Identifies network bottlenecks and validates connection speed against expectations.',
fields: ['target', 'test_duration', 'test_type', 'timeout'],
defaults: { target: 'https://speed.cloudflare.com/__down', test_duration: 10, test_type: 'download', timeout: 30000 },
category: "Performance"
},
packetLossCheck: {
name: 'Packet Loss Check',
description: 'Measure packet loss percentage',
details: 'Sends multiple connection attempts to measure packet loss rates over the network path. High packet loss indicates network congestion or hardware issues.',
fields: ['target', 'port', 'packet_count', 'interval_ms', 'timeout'],
defaults: { target: '', port: 443, packet_count: 20, interval_ms: 100, timeout: 1000 },
category: "Performance"
},
jitterCheck: {
name: 'Network Jitter Check',
description: 'Measure connection time variance',
details: 'Measures variance in connection times to assess network stability and consistency. High jitter indicates unstable network conditions that can affect real-time applications.',
fields: ['target', 'port', 'sample_count', 'interval_ms', 'timeout'],
defaults: { target: '', port: 443, sample_count: 10, interval_ms: 500, timeout: 5000 },
category: "Performance"
},
// ================== NETWORK ANALYSIS ==================
mtuDiscoveryCheck: {
name: 'MTU Discovery',
description: 'Find optimal packet size',
details: 'Discovers the Maximum Transmission Unit (MTU) size along the network path by testing progressively larger packets. Optimizes network performance by avoiding packet fragmentation.',
fields: ['target', 'start_size', 'max_size', 'timeout'],
defaults: { target: '', start_size: 1500, max_size: 9000, timeout: 10000 },
category: "Analysis"
},
tracerouteCheck: {
name: 'Network Path Trace',
description: 'Trace route to destination',
details: 'Maps the complete network path from source to destination, showing each router hop along the way. Essential for diagnosing routing issues and network topology analysis.',
fields: ['target', 'max_hops', 'timeout_per_hop', 'resolve_hostnames'],
defaults: { target: '', max_hops: 30, timeout_per_hop: 5000, resolve_hostnames: true },
category: "Analysis"
},
// ================== CDN PERFORMANCE ==================
cdnCheck: {
name: 'CDN Performance Check',
description: 'Check CDN edge server performance',
details: 'Evaluates Content Delivery Network performance including edge server response times, geographic routing efficiency, and content delivery optimization.',
fields: ['target', 'expected_region', 'check_headers', 'timeout'],
defaults: { target: '', expected_region: 'auto', check_headers: true, timeout: 10000 },
category: "CDN"
}
};
// Get all available check types
export function getAllCheckTypes(): string[] {
return Object.keys(CHECK_TYPES);
}
// Get check types by category
export function getCheckTypesByCategory(category: string): string[] {
return Object.keys(CHECK_TYPES).filter(key => CHECK_TYPES[key].category === category);
}
// Get all available categories
export function getAllCategories(): string[] {
const categories = new Set(Object.values(CHECK_TYPES).map(config => config.category));
return Array.from(categories).sort();
}
// Validate check configuration based on check type
export function validateCheckConfig(checkType: string, config: CheckConfig): string[] {
const checkTypeConfig = CHECK_TYPES[checkType];
if (!checkTypeConfig) {
return [`Unknown check type: ${checkType}`];
}
const errors: string[] = [];
// Check required fields
checkTypeConfig.fields.forEach(field => {
const value = config[field as keyof CheckConfig];
// Check for required target/domain fields
if ((field === 'target' || field === 'domain') && (!value || (typeof value === 'string' && !value.trim()))) {
errors.push(`${field} is required for ${checkTypeConfig.name}`);
}
// Validate port numbers
if (field === 'port' && value !== undefined) {
const port = typeof value === 'number' ? value : parseInt(value as string);
if (isNaN(port) || port < 1 || port > 65535) {
errors.push(`Port must be a valid number between 1 and 65535`);
}
}
// Validate timeout values
if (field === 'timeout' && value !== undefined) {
const timeout = typeof value === 'number' ? value : parseInt(value as string);
if (isNaN(timeout) || timeout < 100 || timeout > 300000) {
errors.push(`Timeout must be between 100ms and 5 minutes`);
}
}
// Validate other numeric fields
if (['attempts', 'packet_count', 'sample_count', 'max_hops'].includes(field) && value !== undefined) {
const numValue = typeof value === 'number' ? value : parseInt(value as string);
if (isNaN(numValue) || numValue < 1) {
errors.push(`${field} must be a positive number`);
}
}
});
return errors;
}

183
src/lib/stores/nodes.ts Normal file
View File

@@ -0,0 +1,183 @@
import { writable, derived, type Writable, type Readable } from 'svelte/store';
import { commands } from '../tauri-commands';
import type { NetworkNode, ValidationResult, Test } from '../types';
// Store for all network nodes
export const nodes: Writable<NetworkNode[]> = writable([]);
// Node management functions
export const nodeActions = {
async add(node: Omit<NetworkNode, 'id' | 'createdAt' | 'updatedAt'>): Promise<NetworkNode> {
try {
// Add ID and timestamps
const newNode: NetworkNode = {
...node,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
// Update store
nodes.update(current => [...current, newNode]);
// Persist to Tauri
await commands.saveNode(newNode);
return newNode;
} catch (error) {
console.error('Failed to add node:', error);
throw error;
}
},
async update(id: string, updates: Partial<NetworkNode>): Promise<NetworkNode> {
try {
const updatedNode: NetworkNode = {
...updates as NetworkNode,
id,
updatedAt: new Date().toISOString()
};
nodes.update(current =>
current.map(node => node.id === id ? updatedNode : node)
);
await commands.updateNode(id, updatedNode);
return updatedNode;
} catch (error) {
console.error('Failed to update node:', error);
throw error;
}
},
async delete(id: string): Promise<void> {
try {
nodes.update(current => current.filter(node => node.id !== id));
await commands.deleteNode(id);
} catch (error) {
console.error('Failed to delete node:', error);
throw error;
}
},
async duplicate(id: string): Promise<NetworkNode> {
try {
// Get current nodes synchronously
let currentNodes: NetworkNode[] = [];
const unsubscribe = nodes.subscribe(value => {
currentNodes = value;
});
unsubscribe(); // Immediately unsubscribe after getting the value
const original = currentNodes.find(node => node.id === id);
if (!original) throw new Error('Node not found');
const duplicate: Omit<NetworkNode, 'id' | 'createdAt' | 'updatedAt'> = {
...original,
name: `${original.name} (Copy)`
};
return await this.add(duplicate);
} catch (error) {
console.error('Failed to duplicate node:', error);
throw error;
}
}
};
export function validateNode(node: Partial<NetworkNode>): string[] {
const errors: string[] = [];
if (!node.name?.trim()) {
errors.push('Name is required');
}
if (!node.domain?.trim() && !node.ip?.trim()) {
errors.push('Either domain or IP address is required');
}
if (node.ip && !isValidIP(node.ip)) {
errors.push('Invalid IP address format');
}
if (node.defaultPort && (!Number.isInteger(Number(node.defaultPort)) || Number(node.defaultPort) < 1 || Number(node.defaultPort) > 65535)) {
errors.push('Port must be between 1 and 65535');
}
return errors;
}
function isValidIP(ip: string): boolean {
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipRegex.test(ip);
}
// Helper to get node by ID
export async function getNodeById(nodeId: string): Promise<NetworkNode | undefined> {
return new Promise(resolve => {
const unsubscribe = nodes.subscribe(current => {
unsubscribe();
resolve(current.find(node => node.id === nodeId));
});
});
}
// Helper to get nodes referenced in a test
export function getNodesReferencedInTest(test: Test): string[] {
const referencedNodeIds = new Set<string>();
// Look for node references in test configurations
function findNodeReferences(obj: any): void {
if (typeof obj === 'string' && obj.startsWith('{{') && obj.endsWith('}}')) {
// Extract node reference like {{node-id.field}} or {{node-id}}
const match = obj.match(/^\{\{([^.}]+)(?:\.[^}]+)?\}\}$/);
if (match) {
referencedNodeIds.add(match[1]);
}
} else if (typeof obj === 'object' && obj !== null) {
Object.values(obj).forEach(findNodeReferences);
}
}
// Search through all test configurations
test.layers?.forEach(layer => {
layer.checks?.forEach(test => {
findNodeReferences(test.config);
});
});
return Array.from(referencedNodeIds);
}
// Computed store for getting referenced nodes for a test
export function getReferencedNodes(test: Test): Readable<NetworkNode[]> {
return derived(nodes, ($nodes) => {
const referencedIds = getNodesReferencedInTest(test);
return $nodes.filter(node => referencedIds.includes(node.id));
});
}
// Helper to create a blank node
export function createBlankNode(): Omit<NetworkNode, 'id' | 'createdAt' | 'updatedAt'> {
return {
name: '',
domain: '',
ip: '',
defaultPort: undefined,
path: '',
description: ''
};
}
// Load nodes on app start
export async function loadNodes(): Promise<void> {
try {
const loadedNodes = await commands.getNodes();
nodes.set(loadedNodes);
} catch (error) {
console.error('Failed to load nodes:', error);
// Set empty array on error
nodes.set([]);
}
}

152
src/lib/stores/tests.ts Normal file
View File

@@ -0,0 +1,152 @@
import { writable, derived, type Writable, type Readable } from 'svelte/store';
import { commands } from '../tauri-commands';
import type { Test, NetworkNode, CheckConfig } from '../types';
import { CHECK_TYPES } from './checks';
// Store for all tests
export const tests: Writable<Test[]> = writable([]);
// Test management functions
export const testActions = {
async add(test: Omit<Test, 'id' | 'createdAt' | 'updatedAt'>): Promise<Test> {
try {
const newTest: Test = {
...test,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
tests.update(current => [...current, newTest]);
await commands.saveTest(newTest);
return newTest;
} catch (error) {
console.error('Failed to add test:', error);
throw error;
}
},
async update(id: string, updates: Partial<Test>): Promise<Test> {
try {
const updatedTest: Test = {
...updates as Test,
id,
updatedAt: new Date().toISOString()
};
tests.update(current =>
current.map(t => t.id === id ? updatedTest : t)
);
await commands.updateTest(id, updatedTest);
return updatedTest;
} catch (error) {
console.error('Failed to update test:', error);
throw error;
}
},
async delete(id: string): Promise<void> {
try {
tests.update(current => current.filter(t => t.id !== id));
await commands.deleteTest(id);
} catch (error) {
console.error('Failed to delete test:', error);
throw error;
}
},
async duplicate(id: string): Promise<Test> {
try {
// Get current test synchronously
let currentTests: Test[] = [];
const unsubscribe = tests.subscribe(value => {
currentTests = value;
});
unsubscribe(); // Immediately unsubscribe after getting the value
const original = currentTests.find(t => t.id === id);
if (!original) throw new Error('Test not found');
const duplicate: Omit<Test, 'id' | 'createdAt' | 'updatedAt'> = {
...original,
name: `${original.name} (Copy)`
};
return await this.add(duplicate);
} catch (error) {
console.error('Failed to duplicate test:', error);
throw error;
}
}
};
// Validation functions
export function validateTest(test: Test, availableNodes: NetworkNode[] = []): string[] {
const errors: string[] = [];
if (!test.name?.trim()) {
errors.push('Name is required');
}
if (!test.layers || !Array.isArray(test.layers) || test.layers.length === 0) {
errors.push('At least one layer is required');
}
// Validate layers
test.layers?.forEach((layer, layerIndex) => {
if (!layer.name?.trim()) {
errors.push(`Layer ${layerIndex + 1}: Name is required`);
}
if (!layer.checks || !Array.isArray(layer.checks) || layer.checks.length === 0) {
errors.push(`Layer ${layerIndex + 1}: At least one test is required`);
}
// Validate checks
layer.checks?.forEach((check, checkIndex) => {
if (!check.type || !CHECK_TYPES[check.type]) {
errors.push(`Layer ${layerIndex + 1}, Test ${checkIndex + 1}: Invalid test type`);
}
// Validate check configuration based on test type
const testType = CHECK_TYPES[check.type];
if (testType) {
testType.fields.forEach(field => {
const value = check.config[field as keyof CheckConfig];
if (field === 'target' || field === 'domain') {
if (!value || (typeof value === 'string' && !value.trim())) {
errors.push(`Layer ${layerIndex + 1}, Test ${checkIndex + 1}: ${field} is required`);
}
}
});
}
});
});
return errors;
}
// Helper to create a blank test
export function createBlankTest(): Omit<Test, 'id' | 'createdAt' | 'updatedAt'> {
return {
name: '',
description: '',
version: '1.0',
layers: []
};
}
// Load tests on app start
export async function loadTests(): Promise<void> {
try {
const loadedTests = await commands.getTests();
tests.set(loadedTests);
} catch (error) {
console.error('Failed to load tests:', error);
// Set empty array on error
tests.set([]);
}
}

View File

@@ -0,0 +1,236 @@
import { writable, derived, type Writable, type Readable } from 'svelte/store';
import { commands } from '../tauri-commands';
import type { Topology, TestTypeConfig, NetworkNode, TestConfig } from '../types';
import { getNodesReferencedInTopology } from './nodes';
// Store for all topologies
export const topologies: Writable<Topology[]> = writable([]);
// Test types available for topology creation with defaults
export const TEST_TYPES: Record<string, TestTypeConfig> = {
// Basic connectivity tests
connectivity_test: {
name: 'Connectivity Test',
description: 'Tests HTTP/HTTPS connection to a domain name (requires DNS resolution)',
fields: ['target', 'port', 'protocol', 'timeout'],
defaults: { target: '', port: 443, protocol: 'https', timeout: 5000 }
},
dns_resolution: {
name: 'DNS Resolution',
description: 'Tests if domain names can be resolved to IP addresses',
fields: ['domain', 'timeout'],
defaults: { domain: '', timeout: 5000 }
},
dns_over_https: {
name: 'DNS over HTTPS',
description: 'Tests secure DNS queries through HTTPS (Pi-hole, Cloudflare, etc.)',
fields: ['target', 'test_domain', 'service_type', 'timeout'],
defaults: { target: '', test_domain: 'google.com', service_type: 'auto', timeout: 5000 }
},
service_health: {
name: 'Service Health',
description: 'Tests if a web service returns expected HTTP status code',
fields: ['target', 'port', 'path', 'expected_status', 'timeout'],
defaults: { target: '', port: 80, path: '/', expected_status: 200, timeout: 5000 }
},
response_time: {
name: 'Response Time',
description: 'Measures connection latency and validates it meets threshold',
fields: ['target', 'port', 'timeout', 'max_response_time'],
defaults: { target: '', port: 443, timeout: 5000, max_response_time: 1000 }
},
ping_test: {
name: 'Ping Test',
description: 'Tests TCP connectivity with multiple attempts and success rate',
fields: ['target', 'port', 'attempts', 'timeout'],
defaults: { target: '', port: 443, attempts: 3, timeout: 5000 }
},
// VPN-specific tests
vpn_connectivity: {
name: 'VPN Endpoint Test',
description: 'Tests if your VPN server endpoint is reachable (WireGuard port 51820)',
fields: ['target', 'port', 'timeout'],
defaults: { target: '', port: 51820, timeout: 5000 }
},
vpn_tunnel: {
name: 'VPN Tunnel Validation',
description: 'Checks if you\'re actually routing through VPN by validating public IP subnet',
fields: ['target', 'timeout'],
defaults: { target: '10.100.0.0/24', timeout: 5000 }
},
// Low-level network tests
local_gateway: {
name: 'Local Network Gateway',
description: 'Tests connectivity to local router/gateway (works without internet)',
fields: ['timeout'],
defaults: { timeout: 3000 }
},
direct_ip: {
name: 'Direct IP Connection',
description: 'Tests connectivity to specific IP address (bypasses DNS completely)',
fields: ['target', 'port', 'timeout'],
defaults: { target: '', port: 443, timeout: 5000 }
},
wellknown_ip: {
name: 'Internet Backbone Test',
description: 'Tests internet connectivity using Google/Cloudflare DNS IPs (no DNS needed)',
fields: ['timeout'],
defaults: { timeout: 3000 }
}
};
// Helper function to get default test configuration
export function getDefaultTestConfig(testType: string): TestConfig {
const testTypeConfig = TEST_TYPES[testType];
if (testTypeConfig && testTypeConfig.defaults) {
return { ...testTypeConfig.defaults };
}
return { timeout: 5000 };
}
// Topology management functions
export const topologyActions = {
async add(topology: Omit<Topology, 'id' | 'createdAt' | 'updatedAt'>): Promise<Topology> {
try {
const newTopology: Topology = {
...topology,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
topologies.update(current => [...current, newTopology]);
await commands.saveTopology(newTopology);
return newTopology;
} catch (error) {
console.error('Failed to add topology:', error);
throw error;
}
},
async update(id: string, updates: Partial<Topology>): Promise<Topology> {
try {
const updatedTopology: Topology = {
...updates as Topology,
id,
updatedAt: new Date().toISOString()
};
topologies.update(current =>
current.map(t => t.id === id ? updatedTopology : t)
);
await commands.updateTopology(id, updatedTopology);
return updatedTopology;
} catch (error) {
console.error('Failed to update topology:', error);
throw error;
}
},
async delete(id: string): Promise<void> {
try {
topologies.update(current => current.filter(t => t.id !== id));
await commands.deleteTopology(id);
} catch (error) {
console.error('Failed to delete topology:', error);
throw error;
}
},
async duplicate(id: string): Promise<Topology> {
try {
// Get current topologies synchronously
let currentTopologies: Topology[] = [];
const unsubscribe = topologies.subscribe(value => {
currentTopologies = value;
});
unsubscribe(); // Immediately unsubscribe after getting the value
const original = currentTopologies.find(t => t.id === id);
if (!original) throw new Error('Topology not found');
const duplicate: Omit<Topology, 'id' | 'createdAt' | 'updatedAt'> = {
...original,
name: `${original.name} (Copy)`
};
return await this.add(duplicate);
} catch (error) {
console.error('Failed to duplicate topology:', error);
throw error;
}
}
};
// Validation functions
export function validateTopology(topology: Topology, availableNodes: NetworkNode[] = []): string[] {
const errors: string[] = [];
if (!topology.name?.trim()) {
errors.push('Name is required');
}
if (!topology.layers || !Array.isArray(topology.layers) || topology.layers.length === 0) {
errors.push('At least one layer is required');
}
// Validate layers
topology.layers?.forEach((layer, layerIndex) => {
if (!layer.name?.trim()) {
errors.push(`Layer ${layerIndex + 1}: Name is required`);
}
if (!layer.tests || !Array.isArray(layer.tests) || layer.tests.length === 0) {
errors.push(`Layer ${layerIndex + 1}: At least one test is required`);
}
// Validate tests
layer.tests?.forEach((test, testIndex) => {
if (!test.type || !TEST_TYPES[test.type]) {
errors.push(`Layer ${layerIndex + 1}, Test ${testIndex + 1}: Invalid test type`);
}
// Validate test configuration based on test type
const testType = TEST_TYPES[test.type];
if (testType) {
testType.fields.forEach(field => {
const value = test.config[field as keyof TestConfig];
if (field === 'target' || field === 'domain') {
if (!value || (typeof value === 'string' && !value.trim())) {
errors.push(`Layer ${layerIndex + 1}, Test ${testIndex + 1}: ${field} is required`);
}
}
});
}
});
});
return errors;
}
// Helper to create a blank topology
export function createBlankTopology(): Omit<Topology, 'id' | 'createdAt' | 'updatedAt'> {
return {
name: '',
description: '',
version: '1.0',
layers: []
};
}
// Load topologies on app start
export async function loadTopologies(): Promise<void> {
try {
const loadedTopologies = await commands.getTopologies();
topologies.set(loadedTopologies);
} catch (error) {
console.error('Failed to load topologies:', error);
// Set empty array on error
topologies.set([]);
}
}

137
src/lib/stores/ui.ts Normal file
View File

@@ -0,0 +1,137 @@
import { writable, derived, type Writable, type Readable } from 'svelte/store';
import type { NotificationItem, ModalState, LoadingState } from '../types';
// UI state management
export const activeTab: Writable<string> = writable('diagnostics');
// Modal state
export const modal: Writable<ModalState> = writable({
isOpen: false,
component: null,
props: {},
title: ''
});
// Notification system
export const notifications: Writable<NotificationItem[]> = writable([]);
// App-wide loading states
export const loading: Writable<LoadingState> = writable({
global: false,
diagnostics: false,
nodes: false,
tests: false
});
// Modal management functions
export const modalActions = {
open(component: any, props: Record<string, any> = {}, title: string = ''): void {
modal.set({
isOpen: true,
component,
props,
title
});
},
close(): void {
modal.set({
isOpen: false,
component: null,
props: {},
title: ''
});
},
updateProps(newProps: Record<string, any>): void {
modal.update(current => ({
...current,
props: { ...current.props, ...newProps }
}));
}
};
// Notification management
export const notificationActions = {
add(message: string, type: NotificationItem['type'] = 'info', duration: number = 5000): string {
const id = crypto.randomUUID();
const notification: NotificationItem = {
id,
message,
type,
duration,
createdAt: Date.now()
};
notifications.update(current => [...current, notification]);
// Auto-remove after duration
if (duration > 0) {
setTimeout(() => {
this.remove(id);
}, duration);
}
return id;
},
remove(id: string): void {
notifications.update(current => current.filter(n => n.id !== id));
},
clear(): void {
notifications.set([]);
},
success(message: string, duration: number = 5000): string {
return this.add(message, 'success', duration);
},
error(message: string, duration: number = 8000): string {
return this.add(message, 'error', duration);
},
warning(message: string, duration: number = 6000): string {
return this.add(message, 'warning', duration);
},
info(message: string, duration: number = 5000): string {
return this.add(message, 'info', duration);
}
};
// Loading state management
export const loadingActions = {
setGlobal(isLoading: boolean): void {
loading.update(current => ({ ...current, global: isLoading }));
},
setDiagnostics(isLoading: boolean): void {
loading.update(current => ({ ...current, diagnostics: isLoading }));
},
setNodes(isLoading: boolean): void {
loading.update(current => ({ ...current, nodes: isLoading }));
},
setTests(isLoading: boolean): void {
loading.update(current => ({ ...current, tests: isLoading }));
}
};
// Keyboard shortcuts
export const shortcuts: Writable<Record<string, () => void>> = writable({
'ctrl+1': () => activeTab.set('diagnostics'),
'ctrl+2': () => activeTab.set('nodes'),
'ctrl+3': () => activeTab.set('tests'),
'escape': () => modalActions.close()
});
// Derived stores
export const isAnyLoading: Readable<boolean> = derived(loading, $loading =>
Object.values($loading).some(Boolean)
);
export const hasNotifications: Readable<boolean> = derived(notifications, $notifications =>
$notifications.length > 0
);

214
src/lib/tauri-commands.ts Normal file
View File

@@ -0,0 +1,214 @@
// src/lib/tauri-commands.ts (Refactored - single command)
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
import type { NetworkNode, Test, DiagnosticResults, CheckResult, CheckConfig } from './types';
import { CHECK_TYPES } from './stores/checks';
// Enhanced logging for desktop debugging
function debugLog(level: 'info' | 'warn' | 'error', message: string, data?: any) {
const timestamp = new Date().toISOString().slice(11, 23);
const prefix = `[${timestamp}] [TAURI-${level.toUpperCase()}]`;
if (data) {
console[level](`${prefix} ${message}`, data);
} else {
console[level](`${prefix} ${message}`);
}
}
// Simple wrapper around Tauri's invoke function with enhanced debugging
export async function invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
const startTime = performance.now();
debugLog('info', `Calling command: ${command}`, args);
try {
const result = await tauriInvoke(command, args);
const duration = Math.round(performance.now() - startTime);
debugLog('info', `Command ${command} succeeded in ${duration}ms`, {
result: typeof result === 'object' && result ? `${Object.keys(result).length} properties` : result,
duration
});
return result as T;
} catch (error) {
const duration = Math.round(performance.now() - startTime);
debugLog('error', `Command ${command} failed after ${duration}ms`, {
error: error instanceof Error ? error.message : error,
args,
duration
});
throw error;
}
}
// Universal check function - all checks use the single execute_check command
async function executeCheck(checkType: string, config: CheckConfig): Promise<CheckResult> {
const checkInfo = CHECK_TYPES[checkType];
if (!checkInfo) {
throw new Error(`Unknown check type: ${checkType}`);
}
debugLog('info', `Running ${checkInfo.name}`, config);
const result = await invoke<CheckResult>('execute_check', {
check_type: checkType,
config: config as Record<string, unknown>
});
debugLog('info', `${checkInfo.name} ${result.success ? 'PASSED' : 'FAILED'}`, {
duration: result.duration,
message: result.message
});
return result;
}
// Generate check functions dynamically from CHECK_TYPES - all use the same executeCheck
const checkFunctions = Object.keys(CHECK_TYPES).reduce((acc, checkType) => {
acc[checkType] = (config: CheckConfig) => executeCheck(checkType, config);
return acc;
}, {} as Record<string, (config: CheckConfig) => Promise<CheckResult>>);
// Static command wrappers (non-check related)
const staticCommands = {
// Node operations
getNodes: async () => {
debugLog('info', 'Fetching all nodes...');
const result = await invoke<NetworkNode[]>('get_nodes');
debugLog('info', `Retrieved ${result.length} nodes`);
return result;
},
saveNode: async (node: NetworkNode) => {
debugLog('info', `Saving node: ${node.name} (ID: ${node.id})`);
const result = await invoke<boolean>('save_node', { node });
debugLog('info', `Node save result: ${result}`);
return result;
},
updateNode: async (id: string, node: NetworkNode) => {
debugLog('info', `Updating node: ${id} -> ${node.name}`);
const result = await invoke<boolean>('update_node', { id, node });
debugLog('info', `Node update result: ${result}`);
return result;
},
deleteNode: async (id: string) => {
debugLog('info', `Deleting node: ${id}`);
const result = await invoke<boolean>('delete_node', { id });
debugLog('info', `Node delete result: ${result}`);
return result;
},
// Test operations
getTests: async () => {
debugLog('info', 'Fetching all tests...');
const result = await invoke<Test[]>('get_tests');
debugLog('info', `Retrieved ${result.length} tests`);
return result;
},
saveTest: async (test: Test) => {
debugLog('info', `Saving test: ${test.name}`, {
id: test.id,
layers: test.layers.length,
totalChecks: test.layers.reduce((sum, layer) => sum + layer.checks.length, 0)
});
const result = await invoke<boolean>('save_test', { test });
debugLog('info', `Test save result: ${result}`);
return result;
},
updateTest: async (id: string, test: Test) => {
debugLog('info', `Updating test: ${id} -> ${test.name}`);
const result = await invoke<boolean>('update_test', { id, test });
debugLog('info', `Test update result: ${result}`);
return result;
},
deleteTest: async (id: string) => {
debugLog('info', `Deleting test: ${id}`);
const result = await invoke<boolean>('delete_test', { id });
debugLog('info', `Test delete result: ${result}`);
return result;
},
// Diagnostic operations
runDiagnostics: async (test: Test) => {
debugLog('info', `Running diagnostics for test: ${test.name}`, {
layers: test.layers.length,
totalChecks: test.layers.reduce((sum, layer) => sum + layer.checks.length, 0)
});
const startTime = performance.now();
const result = await invoke<DiagnosticResults>('run_diagnostics', { test });
const duration = Math.round(performance.now() - startTime);
debugLog('info', `Diagnostics completed in ${duration}ms`, {
success: result.success,
totalDuration: result.totalDuration,
layersCompleted: result.layers.length,
failedLayers: result.layers.filter(l => !l.success).length
});
return result;
},
getDiagnosticResults: async () => {
debugLog('info', 'Fetching diagnostic results...');
const result = await invoke<DiagnosticResults | null>('get_diagnostic_results');
debugLog('info', result ? 'Found previous diagnostic results' : 'No previous diagnostic results');
return result;
},
// File operations
exportData: async (type: string, data: any, filename: string) => {
debugLog('info', `Exporting ${type} data to ${filename}`);
const result = await invoke<boolean>('export_data', { type, data, filename });
debugLog('info', `Export result: ${result}`);
return result;
},
importData: async (type: string, filepath: string) => {
debugLog('info', `Importing ${type} data from ${filepath}`);
const result = await invoke<{ success: boolean; data: any }>('import_data', { type, filepath });
debugLog('info', `Import result: ${result.success}`);
return result;
}
};
// Combine static commands with dynamically generated check functions
export const commands = {
...staticCommands,
...checkFunctions
} as typeof staticCommands & Record<string, (config: CheckConfig) => Promise<CheckResult>>;
// Add debug helper to window for manual debugging
if (typeof window !== 'undefined') {
(window as any).tauriDebug = {
commands,
executeCommand: async (command: string, args: any = {}) => {
debugLog('info', `Manual command: ${command}`, args);
try {
const result = await invoke(command, args);
debugLog('info', `Manual command result:`, result);
return result;
} catch (error) {
debugLog('error', `Manual command failed:`, error);
throw error;
}
},
executeCheck: (checkType: string, config: CheckConfig) => executeCheck(checkType, config),
enableVerbose: () => {
console.log('Verbose Tauri debugging enabled');
(window as any).TAURI_DEBUG = true;
},
listAvailableChecks: () => {
console.log('Available check functions:', Object.keys(checkFunctions));
console.log('Available check types:', Object.keys(CHECK_TYPES));
}
};
}

219
src/lib/types/index.ts Normal file
View File

@@ -0,0 +1,219 @@
// Core type definitions for the Network Diagnostic Tool
// Simplified NetworkNode without type restrictions
export interface NetworkNode {
id: string;
name: string;
domain?: string;
ip?: string;
defaultPort?: number;
path?: string; // For DNS over HTTPS endpoints, service paths, etc.
description?: string;
createdAt: string;
updatedAt: string;
}
export interface CheckConfig {
target?: string;
port?: number;
protocol?: 'http' | 'https';
timeout?: number;
domain?: string;
test_domain?: string;
service_type?: 'google' | 'cloudflare' | 'pihole' | 'auto';
path?: string;
expected_status?: number;
max_response_time?: number;
attempts?: number;
// Email server fields
use_tls?: boolean;
use_ssl?: boolean;
// SSL certificate fields
min_days_until_expiry?: number;
check_chain?: boolean;
// Local network fields
interface?: string;
subnet?: string;
concurrent_scans?: number;
// Protocol-specific fields
passive_mode?: boolean;
check_banner?: boolean;
db_type?: string;
// Performance test fields
test_duration?: number;
test_type?: 'download' | 'upload';
packet_count?: number;
interval_ms?: number;
sample_count?: number;
// Advanced test fields
start_size?: number;
max_size?: number;
max_hops?: number;
timeout_per_hop?: number;
resolve_hostnames?: boolean;
port_range?: string;
scan_type?: 'tcp' | 'udp';
// CDN fields
expected_region?: string;
check_headers?: boolean;
// Additional protocol fields
max_time_drift?: number;
bind_dn?: string;
transport?: 'udp' | 'tcp';
}
export interface Check {
type: string;
config: CheckConfig;
}
export interface Layer {
id: string;
name: string;
description: string;
checks: Check[];
failureActions?: string[];
}
export interface Test {
id?: string;
name: string;
description: string;
version: string;
layers: Layer[];
createdAt?: string;
updatedAt?: string;
}
export interface CheckResult {
type: string;
config: CheckConfig;
success: boolean;
message: string;
error?: string;
details?: any;
duration: number;
startTime: number;
endTime: number;
}
export interface LayerResult {
id: string;
name: string;
description: string;
checks: CheckResult[];
success: boolean;
startTime: number;
endTime: number;
duration: number;
failureActions?: string[];
}
export interface DiagnosticResults {
timestamp: string;
test: string;
layers: LayerResult[];
success: boolean;
totalDuration: number;
}
export interface CheckTypeConfig {
name: string;
description: string;
details: string;
fields: string[];
defaults: CheckConfig;
category: string;
}
export interface NotificationItem {
id: string;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
duration: number;
createdAt: number;
}
export interface ModalState {
isOpen: boolean;
component: any;
props: Record<string, any>;
title: string;
}
export interface LoadingState {
global: boolean;
diagnostics: boolean;
nodes: boolean;
tests: boolean;
}
export interface ValidationResult {
valid: boolean;
errors: string[];
}
// Tauri command types
export interface TauriCommands {
get_nodes(): Promise<NetworkNode[]>;
save_node(args: { node: NetworkNode }): Promise<boolean>;
update_node(args: { id: string; node: NetworkNode }): Promise<boolean>;
delete_node(args: { id: string }): Promise<boolean>;
get_tests(): Promise<Test[]>;
save_test(args: { test: Test }): Promise<boolean>;
update_test(args: { id: string; test: Test }): Promise<boolean>;
delete_test(args: { id: string }): Promise<boolean>;
run_diagnostics(args: { test: Test }): Promise<DiagnosticResults>;
get_diagnostic_results(): Promise<DiagnosticResults | null>;
// Basic checks
check_connectivity(args: CheckConfig): Promise<CheckResult>;
check_dns_resolution(args: CheckConfig): Promise<CheckResult>;
check_ping(args: CheckConfig): Promise<CheckResult>;
// Email checks
check_smtp(args: CheckConfig): Promise<CheckResult>;
check_imap(args: CheckConfig): Promise<CheckResult>;
check_pop3(args: CheckConfig): Promise<CheckResult>;
// Security checks
check_ssl_certificate(args: CheckConfig): Promise<CheckResult>;
// Local checks
check_dhcp_discovery(args: CheckConfig): Promise<CheckResult>;
check_subnet_scan(args: CheckConfig): Promise<CheckResult>;
// Protocol checks
check_ftp(args: CheckConfig): Promise<CheckResult>;
check_ssh(args: CheckConfig): Promise<CheckResult>;
check_database(args: CheckConfig): Promise<CheckResult>;
check_ntp(args: CheckConfig): Promise<CheckResult>;
check_ldap(args: CheckConfig): Promise<CheckResult>;
check_sip(args: CheckConfig): Promise<CheckResult>;
// Performance checks
check_bandwidth(args: CheckConfig): Promise<CheckResult>;
check_packet_loss(args: CheckConfig): Promise<CheckResult>;
check_jitter(args: CheckConfig): Promise<CheckResult>;
// Advanced checks
check_mtu_discovery(args: CheckConfig): Promise<CheckResult>;
check_traceroute(args: CheckConfig): Promise<CheckResult>;
check_port_scan(args: CheckConfig): Promise<CheckResult>;
// CDN checks
check_cdn(args: CheckConfig): Promise<CheckResult>;
export_data(args: { type: string; data: any; filename: string }): Promise<boolean>;
import_data(args: { type: string; filepath: string }): Promise<{ success: boolean; data: any }>;
}

1
src/main.ts Normal file
View File

@@ -0,0 +1 @@
import './app.css'

1
src/routes/+layout.ts Normal file
View File

@@ -0,0 +1 @@
import '../app.css';

73
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { onMount } from 'svelte';
import { commands } from '../lib/tauri-commands';
// Components
import Sidebar from '../lib/components/Sidebar.svelte';
import DiagnosticsTab from '../lib/components/tabs/DiagnosticsTab.svelte';
import NodesTab from '../lib/components/tabs/NodesTab.svelte';
import TestsTab from '../lib/components/tabs/TestsTab.svelte';
import Modal from '../lib/components/Modal.svelte';
import Notifications from '../lib/components/Notifications.svelte';
// Stores
import { activeTab } from '../lib/stores/ui';
import { nodes } from '../lib/stores/nodes';
import { tests } from '../lib/stores/tests';
// Initialize app
onMount(async () => {
try {
// Load saved data from Tauri
const [savedNodes, savedTests] = await Promise.all([
commands.getNodes(),
commands.getTests()
]);
if (savedNodes) nodes.set(savedNodes);
if (savedTests) tests.set(savedTests);
console.log('App initialized successfully');
} catch (error) {
console.warn('Failed to load saved data:', error);
// Continue with empty state - this is expected when running with stubs
}
});
</script>
<div class="min-h-screen bg-gray-900 text-white flex">
<!-- Sidebar -->
<Sidebar />
<!-- Main Content Area -->
<div class="flex-1 flex flex-col min-h-screen">
<!-- Content -->
<main class="flex-1 p-8">
{#if $activeTab === 'diagnostics'}
<DiagnosticsTab />
{:else if $activeTab === 'nodes'}
<NodesTab />
{:else if $activeTab === 'tests'}
<TestsTab />
{/if}
</main>
</div>
<!-- Global Modal Container -->
<Modal />
<!-- Global Notifications -->
<Notifications />
</div>
<style>
:global(html) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
:global(body) {
margin: 0;
padding: 0;
background: #111827;
}
</style>

1
static/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

18
svelte.config.js Normal file
View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

30
tailwind.config.js Normal file
View File

@@ -0,0 +1,30 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
},
gray: {
800: '#1f2937',
900: '#111827',
},
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
},
fontFamily: {
mono: ['Monaco', 'Consolas', 'Liberation Mono', 'monospace'],
},
},
},
plugins: [
require('@tailwindcss/forms'),
],
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
},
"exclude": [
"node_modules/**/*",
".svelte-kit/**/*"
]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});