mirror of
https://github.com/mayanayza/netvisor.git
synced 2025-12-10 08:24:08 -06:00
initial commit
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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-*
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal 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
16
.prettierrc
Normal 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
38
README.md
Normal 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
40
eslint.config.js
Normal 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
4184
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
55
src/app.css
Normal file
55
src/app.css
Normal 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
13
src/app.d.ts
vendored
Normal 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
13
src/app.html
Normal 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>
|
||||
18
src/lib/components/Header.svelte
Normal file
18
src/lib/components/Header.svelte
Normal 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>
|
||||
90
src/lib/components/Modal.svelte
Normal file
90
src/lib/components/Modal.svelte
Normal 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}
|
||||
71
src/lib/components/Notifications.svelte
Normal file
71
src/lib/components/Notifications.svelte
Normal 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>
|
||||
76
src/lib/components/Sidebar.svelte
Normal file
76
src/lib/components/Sidebar.svelte
Normal 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>
|
||||
195
src/lib/components/modals/CheckTypeSelector.svelte
Normal file
195
src/lib/components/modals/CheckTypeSelector.svelte
Normal 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}
|
||||
69
src/lib/components/modals/ConfirmDialog.svelte
Normal file
69
src/lib/components/modals/ConfirmDialog.svelte
Normal 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>
|
||||
218
src/lib/components/modals/NodeEditor.svelte
Normal file
218
src/lib/components/modals/NodeEditor.svelte
Normal 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>
|
||||
143
src/lib/components/modals/TestTypeSelector.svelte
Normal file
143
src/lib/components/modals/TestTypeSelector.svelte
Normal 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}
|
||||
520
src/lib/components/modals/TopologyEditor.svelte
Normal file
520
src/lib/components/modals/TopologyEditor.svelte
Normal 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>
|
||||
178
src/lib/components/modals/test_editor/CheckEditor.svelte
Normal file
178
src/lib/components/modals/test_editor/CheckEditor.svelte
Normal 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>
|
||||
175
src/lib/components/modals/test_editor/LayerEditor.svelte
Normal file
175
src/lib/components/modals/test_editor/LayerEditor.svelte
Normal 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>
|
||||
96
src/lib/components/modals/test_editor/LayerManager.svelte
Normal file
96
src/lib/components/modals/test_editor/LayerManager.svelte
Normal 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>
|
||||
@@ -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>
|
||||
180
src/lib/components/modals/test_editor/TestEditor.svelte
Normal file
180
src/lib/components/modals/test_editor/TestEditor.svelte
Normal 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>
|
||||
@@ -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}
|
||||
88
src/lib/components/shared/Card.svelte
Normal file
88
src/lib/components/shared/Card.svelte
Normal 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>
|
||||
534
src/lib/components/tabs/DiagnosticsTab.svelte
Normal file
534
src/lib/components/tabs/DiagnosticsTab.svelte
Normal 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>
|
||||
101
src/lib/components/tabs/NodesTab.svelte
Normal file
101
src/lib/components/tabs/NodesTab.svelte
Normal 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>
|
||||
138
src/lib/components/tabs/TestsTab.svelte
Normal file
138
src/lib/components/tabs/TestsTab.svelte
Normal 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>
|
||||
223
src/lib/components/tabs/TopologiesTab.svelte
Normal file
223
src/lib/components/tabs/TopologiesTab.svelte
Normal 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
354
src/lib/stores/checks.ts
Normal 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
183
src/lib/stores/nodes.ts
Normal 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
152
src/lib/stores/tests.ts
Normal 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([]);
|
||||
}
|
||||
}
|
||||
236
src/lib/stores/topologies.ts
Normal file
236
src/lib/stores/topologies.ts
Normal 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
137
src/lib/stores/ui.ts
Normal 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
214
src/lib/tauri-commands.ts
Normal 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
219
src/lib/types/index.ts
Normal 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
1
src/main.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './app.css'
|
||||
1
src/routes/+layout.ts
Normal file
1
src/routes/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '../app.css';
|
||||
73
src/routes/+page.svelte
Normal file
73
src/routes/+page.svelte
Normal 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
1
static/favicon.svg
Normal 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
18
svelte.config.js
Normal 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
30
tailwind.config.js
Normal 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
18
tsconfig.json
Normal 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
7
vite.config.ts
Normal 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()]
|
||||
});
|
||||
Reference in New Issue
Block a user