working on dockerizing

This commit is contained in:
Maya
2025-09-25 15:04:18 -04:00
parent 1fda8ec245
commit ca088fabbe
16 changed files with 322 additions and 62 deletions
+46
View File
@@ -0,0 +1,46 @@
# Dependencies
node_modules/
.npm/
.pnpm-store/
# Build artifacts
dist/
build/
.svelte-kit/
# Development files
.env*
*.log
npm-debug.log*
# Git and version control
.git/
.gitignore
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Documentation
README.md
*.md
docs/
# Docker file
Dockerfile*
docker-compose*.yml
.dockerignore
# Temporary files
tmp/
temp/
+35
View File
@@ -0,0 +1,35 @@
FROM node:20-slim as builder
WORKDIR /app
# Install dependencies first (better caching)
COPY package*.json ./
RUN npm ci --only=production --frozen-lockfile
# Copy source code
COPY . .
# Build for production (optimized, minified)
RUN npm run build
# Production stage - minimal runtime
FROM node:20-alpine as runtime
WORKDIR /app
# Copy only the built artifacts
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./
# Install only production dependencies for serving
RUN npm ci --only=production --frozen-lockfile && npm cache clean --force
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && adduser -S sveltekit -u 1001
# Switch to non-root user
USER sveltekit
# Expose port (if you ever want to serve directly from container)
EXPOSE 3000
# Keep container running to share volume
CMD ["tail", "-f", "/dev/null"]
+16
View File
@@ -0,0 +1,16 @@
# Development Dockerfile for netvisor-ui
FROM node:20-slim as base
WORKDIR /app
# Install dependencies first (better caching)
COPY package*.json ./
RUN npm ci --only=development
# Copy source code
COPY . .
# Build in development mode (faster builds, source maps)
RUN npm run build
# Keep container running to share volume
CMD ["tail", "-f", "/dev/null"]
@@ -11,6 +11,7 @@
import { ServiceDisplay } from '$lib/shared/components/forms/selection/display/ServiceDisplay.svelte';
import GroupDetailsForm from './GroupDetailsForm.svelte';
import { pushWarning } from '$lib/shared/stores/feedback';
import EntityMetadataSection from '$lib/shared/components/forms/EntityMetadataSection.svelte';
export let group: Group | null = null;
export let isOpen = false;
@@ -188,6 +189,10 @@
</div>
</div>
</div>
{#if isEditing}
<EntityMetadataSection id={formData.id} createdAt={formData.created_at} updatedAt={formData.updated_at}/>
{/if}
</div>
</div>
</div>
@@ -41,14 +41,10 @@
// Build card data
$: cardData = {
title: host.name,
link: `http://${connectionInfo}`,
iconColor: entities.getColorHelper("Host").icon,
icon: serviceDefinitions.getIconComponent(hostServices[0]?.service_definition) || entities.getIconComponent("Host"),
sections: connectionInfo ? [{
label: 'Connection',
value: connectionInfo,
link: `http://${connectionInfo}`
}] : [],
sections: [],
lists: [
{
label: 'Services',
@@ -63,10 +59,10 @@
},
{
label: 'Subnets',
items: host.interfaces.map(sub => {
items: [...new Set(host.interfaces.map(iface => iface.subnet_id))].map(id => {
return ({
id: sub.subnet_id,
label: getSubnetNameFromId(sub.subnet_id) || "Unknown Subnet",
id: id,
label: getSubnetNameFromId(id) || "Unknown Subnet",
color: entities.getColorHelper("Subnet").string
})
}),
@@ -10,10 +10,12 @@
import type { FormApi, FormType } from '$lib/shared/components/forms/types';
import TextInput from '$lib/shared/components/forms/input/TextInput.svelte';
import TextArea from '$lib/shared/components/forms/input/TextArea.svelte';
import EntityMetadataSection from '$lib/shared/components/forms/EntityMetadataSection.svelte';
export let formApi: FormApi;
export let form: FormType;
export let formData: Host;
export let isEditing: boolean;
// Create form fields with validation
const name = field('name', formData.name, [required(), maxLength(100)]);
@@ -58,4 +60,8 @@
{form}
{formData}
/>
{#if isEditing}
<EntityMetadataSection id={formData.id} createdAt={formData.created_at} updatedAt={formData.updated_at}/>
{/if}
</div>
@@ -8,6 +8,7 @@
import { required } from 'svelte-forms/validators';
import { onMount } from 'svelte';
import type { FieldType, FormApi, FormType } from '$lib/shared/components/forms/types';
import InlineWarning from '$lib/shared/components/feedback/InlineWarning.svelte';
export let formApi: FormApi;
export let form: FormType;
@@ -137,7 +138,7 @@
{/each}
</select>
<p class="text-xs text-gray-400">
How should NetVisor connect to this host?
How should NetVisor display a link for this host?
</p>
</div>
@@ -152,7 +153,10 @@
Network Interface
<span class="text-red-400 ml-1">*</span>
</label>
{#if formData.interfaces.length == 0}
<InlineWarning title="No interfaces available" body="No interfaces available. Add an interface or change target type."/>
{:else}
<RichSelect
selectedValue={selectedInterfaceId || formData.interfaces[0].id}
options={formData.interfaces}
@@ -160,10 +164,7 @@
displayComponent={InterfaceDisplay}
onSelect={handleInterfaceSelect}
/>
<p class="text-xs text-gray-400">
Choose which network interface to use for connecting to this host
</p>
{/if}
</div>
{:else if formData.target.type === 'Hostname'}
@@ -202,6 +202,7 @@
<DetailsForm
{formApi}
{form}
{isEditing}
bind:formData={formData}/>
</div>
</div>
@@ -68,6 +68,7 @@
allowReorder={true}
options={availableServiceTypes}
showSearch={true}
{items}
allowItemRemove={() => true}
@@ -11,6 +11,7 @@
import ModalHeaderIcon from '$lib/shared/components/layout/ModalHeaderIcon.svelte';
import { ServiceAsHostDisplay } from '$lib/shared/components/forms/selection/display/ServiceAsHostDisplay.svelte';
import SubnetDetailsForm from './SubnetDetailsForm.svelte';
import EntityMetadataSection from '$lib/shared/components/forms/EntityMetadataSection.svelte';
export let subnet: Subnet | null = null;
export let isOpen = false;
@@ -242,6 +243,10 @@
</div>
</div>
</div>
{#if isEditing}
<EntityMetadataSection id={formData.id} createdAt={formData.created_at} updatedAt={formData.updated_at}/>
{/if}
</div>
</div>
</div>
@@ -3,6 +3,7 @@
import Tag from './Tag.svelte';
export let title: string;
export let link: string = '';
export let subtitle: string = '';
export let status: string = '';
export let statusColor: string = 'text-gray-400';
@@ -23,7 +24,11 @@
<svelte:component this={icon} size={24} class={iconColor} />
{/if}
<div>
<h3 class="text-lg font-semibold text-white">{title}</h3>
{#if link}
<a href={link} class="text-lg font-semibold text-white hover:text-blue-400" target="_blank">{title}</a>
{:else}
<h3 class="text-lg font-semibold text-white">{title}</h3>
{/if}
{#if subtitle}
<p class="text-sm text-gray-400">{subtitle}</p>
{/if}
@@ -40,11 +45,7 @@
{#each sections as section}
<div class="text-sm text-gray-300">
<span class="text-gray-400">{section.label}:</span>
{#if section.link}
<a href={section.link} class="ml-2 hover:text-blue-400 underline" target="_blank">{section.value}</a>
{:else}
<span class="ml-2">{section.value}</span>
{/if}
</div>
{/each}
@@ -0,0 +1,70 @@
<script lang="ts">
import { formatId, formatTimestamp } from '$lib/shared/utils/formatting';
import { Calendar, Clock, Hash } from 'lucide-svelte';
export let id: string;
export let createdAt: string;
export let updatedAt: string;
export let title: string = "Metadata";
// Copy ID to clipboard
async function copyId() {
try {
await navigator.clipboard.writeText(id);
} catch (error) {
console.warn('Failed to copy ID to clipboard:', error);
}
}
</script>
<div class="border-t border-gray-700 pt-6">
<h3 class="text-lg font-medium text-white mb-4">{title}</h3>
<div class="bg-gray-800/50 rounded-lg p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- ID -->
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<Hash class="h-5 w-5 text-gray-400" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-300">ID</p>
<button
class="text-sm text-gray-400 hover:text-white transition-colors cursor-pointer font-mono truncate block max-w-full"
title={`${id} (Click to copy)`}
on:click={copyId}
>
{formatId(id)}
</button>
</div>
</div>
<!-- Created -->
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<Calendar class="h-5 w-5 text-gray-400" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-300">Created</p>
<p class="text-sm text-gray-400" title={createdAt}>
{formatTimestamp(createdAt)}
</p>
</div>
</div>
<!-- Updated -->
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<Clock class="h-5 w-5 text-gray-400" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-300">Updated</p>
<p class="text-sm text-gray-400" title={updatedAt}>
{formatTimestamp(updatedAt)}
</p>
</div>
</div>
</div>
</div>
</div>
@@ -20,6 +20,7 @@
// Options (dropdown)
export let options: V[] = [];
export let optionDisplayComponent: EntityDisplayComponent<V>;
export let showSearch: boolean = false;
// Items
export let items: T[] = [];
@@ -129,6 +130,7 @@
<div class="flex-1">
<RichSelect
selectedValue={selectedOptionId}
{showSearch}
{options}
{placeholder}
onSelect={handleSelectChange}
@@ -12,26 +12,41 @@
export let disabled: boolean = false;
export let error: string | null = null;
export let onSelect: (value: string) => void;
export let showDescriptionUnderDropdown: boolean = false;
export let showSearch: boolean = false;
export let displayComponent: EntityDisplayComponent<T>;
let isOpen = false;
let dropdownElement: HTMLDivElement;
let triggerElement: HTMLButtonElement;
let inputElement: HTMLInputElement;
let dropdownPosition = { top: 0, left: 0, width: 0 };
let openUpward = false;
let filterText = '';
$: selectedItem = options.find(i => displayComponent.getId(i) === selectedValue);
// Group options by category when getCategory is provided
// Filter options based on search text
$: filteredOptions = options.filter(option => {
if (!filterText.trim()) return true;
const searchTerm = filterText.toLowerCase();
const label = displayComponent.getLabel(option).toLowerCase();
const description = displayComponent.getDescription?.(option)?.toLowerCase() || '';
return label.includes(searchTerm) || description.includes(searchTerm);
});
// Group filtered options by category when getCategory is provided
$: groupedOptions = (() => {
const optionsToGroup = filteredOptions;
if (!displayComponent.getCategory) {
return [{ category: null, options: options }];
return [{ category: null, options: optionsToGroup }];
}
const groups = new Map<string | null, T[]>();
options.forEach(option => {
optionsToGroup.forEach(option => {
const category = displayComponent.getCategory!(option);
if (!groups.has(category)) {
groups.set(category, []);
@@ -76,9 +91,13 @@
if (!disabled) {
if (!isOpen) {
isOpen = true;
filterText = ''; // Reset filter when opening
await calculatePosition(); // Calculate once when opening
// Focus the input after the dropdown is positioned
setTimeout(() => inputElement?.focus(), 0);
} else {
isOpen = false;
filterText = '';
}
}
}
@@ -88,11 +107,13 @@
const item = options.find(i => displayComponent.getId(i) === value);
if (item && !displayComponent.getIsDisabled?.(item)) {
isOpen = false;
filterText = '';
onSelect(value);
}
} catch (e) {
console.warn('Error in handleSelect:', e);
isOpen = false;
filterText = '';
}
}
@@ -100,8 +121,19 @@
if (dropdownElement && !dropdownElement.contains(event.target as Node) &&
triggerElement && !triggerElement.contains(event.target as Node)) {
isOpen = false;
filterText = '';
}
}
function handleInputKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
isOpen = false;
filterText = '';
triggerElement?.focus(); // Return focus to trigger
}
// Prevent the input keydown from bubbling to parent components
e.stopPropagation();
}
</script>
<!-- Only handle outside clicks -->
@@ -143,7 +175,7 @@
</button>
<!-- Description below trigger (optional) -->
{#if selectedItem && displayComponent.getDescription?.(selectedItem) && showDescriptionUnderDropdown}
{#if selectedItem && displayComponent.getDescription?.(selectedItem)}
<div class="mt-2">
<p class="text-sm text-gray-400">
{displayComponent.getDescription(selectedItem)}
@@ -163,41 +195,67 @@
{#if isOpen && !disabled}
<div
bind:this={dropdownElement}
class="fixed z-[9999] bg-gray-700 border border-gray-600 rounded-md shadow-lg max-h-96 overflow-y-auto scroll-smooth"
class="fixed z-[9999] bg-gray-700 border border-gray-600 rounded-md shadow-lg max-h-96 overflow-hidden scroll-smooth"
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; width: {dropdownPosition.width}px;
{openUpward ? 'transform: translateY(-100%);' : ''}"
>
{#each groupedOptions as group, groupIndex}
<!-- Category Header -->
{#if group.category !== null}
<div class="px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wide bg-gray-800 border-b border-gray-600 sticky top-0">
{group.category}
<!-- Search Input -->
{#if showSearch}
<div class="p-2 border-b border-gray-600 bg-gray-700 sticky top-0">
<input
bind:this={inputElement}
bind:value={filterText}
type="text"
placeholder="Type to filter options..."
class="w-full px-2 py-1 bg-gray-800 border border-gray-600 rounded text-white text-sm placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
on:keydown={handleInputKeydown}
on:click|stopPropagation
/>
</div>
{/if}
<!-- Options list with scroll container -->
<div class="overflow-y-auto max-h-80">
{#if groupedOptions.length === 0 || groupedOptions.every(group => group.options.length === 0)}
<div class="px-3 py-4 text-center text-gray-400 text-sm">
No options match "{filterText}"
</div>
{:else}
{#each groupedOptions as group, groupIndex}
{#if group.options.length > 0}
<!-- Category Header -->
{#if group.category !== null}
<div class="px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wide bg-gray-800 border-b border-gray-600 sticky top-0">
{group.category}
</div>
{/if}
<!-- Options in this category -->
{#each group.options as option, optionIndex}
{@const isLastInGroup = optionIndex === group.options.length - 1}
{@const isLastGroup = groupIndex === groupedOptions.length - 1}
<button
type="button"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
if (!displayComponent.getIsDisabled?.(option)) {
handleSelect(displayComponent.getId(option));
}
}}
class="w-full px-3 py-3 text-left transition-colors
{!isLastInGroup || !isLastGroup ? 'border-b border-gray-600' : ''}
{displayComponent.getIsDisabled?.(option) ? 'cursor-not-allowed opacity-50' : 'hover:bg-gray-600'}"
disabled={displayComponent.getIsDisabled?.(option)}
>
<ListSelectItem
item={option}
{displayComponent} />
</button>
{/each}
{/if}
{/each}
{/if}
<!-- Options in this category -->
{#each group.options as option, optionIndex}
{@const isLastInGroup = optionIndex === group.options.length - 1}
{@const isLastGroup = groupIndex === groupedOptions.length - 1}
<button
type="button"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
if (!displayComponent.getIsDisabled?.(option)) {
handleSelect(displayComponent.getId(option));
}
}}
class="w-full px-3 py-3 text-left transition-colors
{!isLastInGroup || !isLastGroup ? 'border-b border-gray-600' : ''}
{displayComponent.getIsDisabled?.(option) ? 'cursor-not-allowed opacity-50' : 'hover:bg-gray-600'}"
disabled={displayComponent.getIsDisabled?.(option)}
>
<ListSelectItem
item={option}
{displayComponent} />
</button>
{/each}
{/each}
</div>
</div>
{/if}
@@ -2,6 +2,7 @@
import type { ServiceBinding } from '$lib/features/groups/types/base';
import type { Interface } from '$lib/features/hosts/types/base';
import { getServiceHost, services } from '$lib/features/services/store';
import { formatId } from '$lib/shared/utils/formatting';
import type { FormApi } from '../../types';
export let serviceBinding: ServiceBinding;
@@ -23,7 +24,7 @@
const parts = [];
if (iface.name) parts.push(iface.name);
if (iface.ip_address) parts.push(iface.ip_address);
return parts.length > 0 ? parts.join(' - ') : iface.id.slice(0, 8);
return parts.length > 0 ? parts.join(' - ') : formatId(iface.id);
}
</script>
+21 -5
View File
@@ -15,11 +15,27 @@ export function formatDuration(startTime: string, endTime?: string) {
return `${Math.round(durationMs / 3600000)}h`;
}
export function formatTimestamp(timestamp: string) {
if (!timestamp) return 'Unknown';
export function formatTimestamp(timestamp: string): string {
try {
return new Date(timestamp).toLocaleString();
} catch {
return 'Invalid date';
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
} catch (error) {
return timestamp; // Fallback to raw string if parsing fails
}
}
// Truncate ID for display (show first 8 characters + ellipsis if longer than 12)
export function formatId(id: string): string {
if (id.length <= 12) {
return id;
}
return `${id.substring(0, 8)}...`;
}