mirror of
https://github.com/mayanayza/netvisor.git
synced 2025-12-10 08:24:08 -06:00
working on dockerizing
This commit is contained in:
@@ -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
@@ -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"]
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}...`;
|
||||
}
|
||||
Reference in New Issue
Block a user