import { Head, router } from '@inertiajs/react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState, useMemo } from 'react' import AppLayout from '~/layouts/AppLayout' import StyledButton from '~/components/StyledButton' import api from '~/lib/api' import { ServiceSlim } from '../../../types/services' import CuratedCollectionCard from '~/components/CuratedCollectionCard' import CategoryCard from '~/components/CategoryCard' import TierSelectionModal from '~/components/TierSelectionModal' import LoadingSpinner from '~/components/LoadingSpinner' import Alert from '~/components/Alert' import { IconCheck, IconChevronDown, IconChevronUp, IconArrowRight } from '@tabler/icons-react' import StorageProjectionBar from '~/components/StorageProjectionBar' import { useNotifications } from '~/context/NotificationContext' import useInternetStatus from '~/hooks/useInternetStatus' import { useSystemInfo } from '~/hooks/useSystemInfo' import classNames from 'classnames' import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads' // Capability definitions - maps user-friendly categories to services interface Capability { id: string name: string technicalName: string description: string features: string[] services: string[] // service_name values that this capability installs icon: string } const CORE_CAPABILITIES: Capability[] = [ { id: 'information', name: 'Information Library', technicalName: 'Kiwix', description: 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias', features: [ 'Complete Wikipedia offline', 'Medical references and first aid guides', 'WikiHow articles and tutorials', 'Project Gutenberg books and literature', ], services: ['nomad_kiwix_serve'], icon: 'IconBooks', }, { id: 'education', name: 'Education Platform', technicalName: 'Kolibri', description: 'Interactive learning platform with video courses and exercises', features: [ 'Khan Academy math and science courses', 'K-12 curriculum content', 'Interactive exercises and quizzes', 'Progress tracking for learners', ], services: ['nomad_kolibri'], icon: 'IconSchool', }, { id: 'ai', name: 'AI Assistant', technicalName: 'Open WebUI + Ollama', description: 'Local AI chat that runs entirely on your hardware - no internet required', features: [ 'Private conversations that never leave your device', 'No internet connection needed after setup', 'Ask questions, get help with writing, brainstorm ideas', 'Runs on your own hardware with local AI models', ], services: ['nomad_open_webui'], // ollama is auto-installed as dependency icon: 'IconRobot', }, ] const ADDITIONAL_TOOLS: Capability[] = [ { id: 'notes', name: 'Notes', technicalName: 'FlatNotes', description: 'Simple note-taking app with local storage', features: ['Markdown support', 'All notes stored locally', 'No account required'], services: ['nomad_flatnotes'], icon: 'IconNotes', }, { id: 'datatools', name: 'Data Tools', technicalName: 'CyberChef', description: 'Swiss Army knife for data encoding, encryption, and analysis', features: [ 'Encode/decode data (Base64, hex, etc.)', 'Encryption and hashing tools', 'Data format conversion', ], services: ['nomad_cyberchef'], icon: 'IconChefHat', }, { id: 'benchmark', name: 'System Benchmark', technicalName: 'Built-in', description: 'Measure your server performance and compare with the NOMAD community', features: [ 'CPU, memory, and disk benchmarks', 'AI inference performance testing', 'NOMAD Score for easy comparison', ], services: ['__builtin_benchmark'], // Special marker for built-in features icon: 'IconChartBar', }, ] type WizardStep = 1 | 2 | 3 | 4 const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections' const CURATED_ZIM_COLLECTIONS_KEY = 'curated-zim-collections' const CURATED_CATEGORIES_KEY = 'curated-categories' // Helper to get all resources for a tier (including inherited resources) const getAllResourcesForTier = ( tier: CategoryTier, allTiers: CategoryTier[] ): CategoryResource[] => { const resources = [...tier.resources] if (tier.includesTier) { const includedTier = allTiers.find((t) => t.slug === tier.includesTier) if (includedTier) { resources.unshift(...getAllResourcesForTier(includedTier, allTiers)) } } return resources } export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) { const [currentStep, setCurrentStep] = useState(1) const [selectedServices, setSelectedServices] = useState([]) const [selectedMapCollections, setSelectedMapCollections] = useState([]) const [selectedZimCollections, setSelectedZimCollections] = useState([]) const [selectedAiModels, setSelectedAiModels] = useState([]) const [isProcessing, setIsProcessing] = useState(false) const [showAdditionalTools, setShowAdditionalTools] = useState(false) // Category/tier selection state const [selectedTiers, setSelectedTiers] = useState>(new Map()) const [tierModalOpen, setTierModalOpen] = useState(false) const [activeCategory, setActiveCategory] = useState(null) const { addNotification } = useNotifications() const { isOnline } = useInternetStatus() const queryClient = useQueryClient() const { data: systemInfo } = useSystemInfo({ enabled: true }) const anySelectionMade = selectedServices.length > 0 || selectedMapCollections.length > 0 || selectedZimCollections.length > 0 || selectedTiers.size > 0 || selectedAiModels.length > 0 const { data: mapCollections, isLoading: isLoadingMaps } = useQuery({ queryKey: [CURATED_MAP_COLLECTIONS_KEY], queryFn: () => api.listCuratedMapCollections(), refetchOnWindowFocus: false, }) const { data: zimCollections, isLoading: isLoadingZims } = useQuery({ queryKey: [CURATED_ZIM_COLLECTIONS_KEY], queryFn: () => api.listCuratedZimCollections(), refetchOnWindowFocus: false, }) // Fetch curated categories with tiers const { data: categories, isLoading: isLoadingCategories } = useQuery({ queryKey: [CURATED_CATEGORIES_KEY], queryFn: () => api.listCuratedCategories(), refetchOnWindowFocus: false, }) const { data: recommendedModels, isLoading: isLoadingRecommendedModels } = useQuery({ queryKey: ['recommended-ollama-models'], queryFn: () => api.getRecommendedModels(), refetchOnWindowFocus: false, }) // All services for display purposes const allServices = props.system.services const availableServices = props.system.services.filter( (service) => !service.installed && service.installation_status !== 'installing' ) // Services that are already installed const installedServices = props.system.services.filter((service) => service.installed) const toggleMapCollection = (slug: string) => { setSelectedMapCollections((prev) => prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug] ) } const toggleZimCollection = (slug: string) => { setSelectedZimCollections((prev) => prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug] ) } const toggleAiModel = (modelName: string) => { setSelectedAiModels((prev) => prev.includes(modelName) ? prev.filter((m) => m !== modelName) : [...prev, modelName] ) } // Category/tier handlers const handleCategoryClick = (category: CuratedCategory) => { if (!isOnline) return setActiveCategory(category) setTierModalOpen(true) } const handleTierSelect = (category: CuratedCategory, tier: CategoryTier) => { setSelectedTiers((prev) => { const newMap = new Map(prev) // If same tier is selected, deselect it if (prev.get(category.slug)?.slug === tier.slug) { newMap.delete(category.slug) } else { newMap.set(category.slug, tier) } return newMap }) } const closeTierModal = () => { setTierModalOpen(false) setActiveCategory(null) } // Get all resources from selected tiers for downloading const getSelectedTierResources = (): CategoryResource[] => { if (!categories) return [] const resources: CategoryResource[] = [] selectedTiers.forEach((tier, categorySlug) => { const category = categories.find((c) => c.slug === categorySlug) if (category) { resources.push(...getAllResourcesForTier(tier, category.tiers)) } }) return resources } // Calculate total projected storage from all selections const projectedStorageBytes = useMemo(() => { let totalBytes = 0 // Add tier resources const tierResources = getSelectedTierResources() totalBytes += tierResources.reduce((sum, r) => sum + (r.size_mb ?? 0) * 1024 * 1024, 0) // Add map collections if (mapCollections) { selectedMapCollections.forEach((slug) => { const collection = mapCollections.find((c) => c.slug === slug) if (collection) { totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0) } }) } // Add ZIM collections if (zimCollections) { selectedZimCollections.forEach((slug) => { const collection = zimCollections.find((c) => c.slug === slug) if (collection) { totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0) } }) } // Add AI models if (recommendedModels) { selectedAiModels.forEach((modelName) => { const model = recommendedModels.find((m) => m.name === modelName) if (model?.tags?.[0]?.size) { // Parse size string like "4.7GB" or "1.5GB" const sizeStr = model.tags[0].size const match = sizeStr.match(/^([\d.]+)\s*(GB|MB|KB)?$/i) if (match) { const value = parseFloat(match[1]) const unit = (match[2] || 'GB').toUpperCase() if (unit === 'GB') { totalBytes += value * 1024 * 1024 * 1024 } else if (unit === 'MB') { totalBytes += value * 1024 * 1024 } else if (unit === 'KB') { totalBytes += value * 1024 } } } }) } return totalBytes }, [ selectedTiers, selectedMapCollections, selectedZimCollections, selectedAiModels, categories, mapCollections, zimCollections, recommendedModels, ]) // Get primary disk/filesystem info for storage projection // Try disk array first (Linux/production), fall back to fsSize (Windows/dev) // Filter out invalid disks (totalSize === 0) and prefer disk with root mount or largest valid disk const getPrimaryDisk = () => { if (!systemInfo?.disk || systemInfo.disk.length === 0) return null // Filter to only valid disks with actual storage const validDisks = systemInfo.disk.filter((d) => d.totalSize > 0) if (validDisks.length === 0) return null // Prefer disk containing root mount (/) or /storage mount const diskWithRoot = validDisks.find((d) => d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage') ) if (diskWithRoot) return diskWithRoot // Fall back to largest valid disk return validDisks.reduce((largest, current) => current.totalSize > largest.totalSize ? current : largest ) } const primaryDisk = getPrimaryDisk() const primaryFs = systemInfo?.fsSize?.[0] const storageInfo = primaryDisk ? { totalSize: primaryDisk.totalSize, totalUsed: primaryDisk.totalUsed } : primaryFs ? { totalSize: primaryFs.size, totalUsed: primaryFs.used } : null const canProceedToNextStep = () => { if (!isOnline) return false // Must be online to proceed if (currentStep === 1) return true // Can skip app installation if (currentStep === 2) return true // Can skip map downloads if (currentStep === 3) return true // Can skip ZIM downloads return false } const handleNext = () => { if (currentStep < 4) { setCurrentStep((prev) => (prev + 1) as WizardStep) } } const handleBack = () => { if (currentStep > 1) { setCurrentStep((prev) => (prev - 1) as WizardStep) } } const handleFinish = async () => { if (!isOnline) { addNotification({ type: 'error', message: 'You must have an internet connection to complete the setup.', }) return } setIsProcessing(true) try { // All of these ops don't actually wait for completion, they just kick off the process, so we can run them in parallel without awaiting each one sequentially const installPromises = selectedServices.map((serviceName) => api.installService(serviceName)) await Promise.all(installPromises) // Download collections, individual tier resources, and AI models const tierResources = getSelectedTierResources() const downloadPromises = [ ...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)), ...selectedZimCollections.map((slug) => api.downloadZimCollection(slug)), ...tierResources.map((resource) => api.downloadRemoteZimFile(resource.url)), ...selectedAiModels.map((modelName) => api.downloadModel(modelName)), ] await Promise.all(downloadPromises) addNotification({ type: 'success', message: 'Setup wizard completed! Your selections are being processed.', }) router.visit('/easy-setup/complete') } catch (error) { console.error('Error during setup:', error) addNotification({ type: 'error', message: 'An error occurred during setup. Some items may not have been processed.', }) } finally { setIsProcessing(false) } } const fetchLatestMapCollections = useMutation({ mutationFn: () => api.fetchLatestMapCollections(), onSuccess: () => { addNotification({ message: 'Successfully fetched the latest map collections.', type: 'success', }) queryClient.invalidateQueries({ queryKey: [CURATED_MAP_COLLECTIONS_KEY] }) }, }) const fetchLatestZIMCollections = useMutation({ mutationFn: () => api.fetchLatestZimCollections(), onSuccess: () => { addNotification({ message: 'Successfully fetched the latest ZIM collections.', type: 'success', }) queryClient.invalidateQueries({ queryKey: [CURATED_ZIM_COLLECTIONS_KEY] }) }, }) // Auto-fetch latest collections if the list is empty useEffect(() => { if (mapCollections && mapCollections.length === 0 && !fetchLatestMapCollections.isPending) { fetchLatestMapCollections.mutate() } }, [mapCollections, fetchLatestMapCollections]) useEffect(() => { if (zimCollections && zimCollections.length === 0 && !fetchLatestZIMCollections.isPending) { fetchLatestZIMCollections.mutate() } }, [zimCollections, fetchLatestZIMCollections]) const renderStepIndicator = () => { const steps = [ { number: 1, label: 'Apps' }, { number: 2, label: 'Maps' }, { number: 3, label: 'Content' }, { number: 4, label: 'Review' }, ] return ( ) } // Check if a capability is a built-in feature (not a Docker service) const isBuiltInCapability = (capability: Capability) => { return capability.services.some((service) => service.startsWith('__builtin_')) } // Check if a capability is selected (all its services are in selectedServices) const isCapabilitySelected = (capability: Capability) => { if (isBuiltInCapability(capability)) return false // Built-ins can't be selected return capability.services.every((service) => selectedServices.includes(service)) } // Check if a capability is already installed (all its services are installed) const isCapabilityInstalled = (capability: Capability) => { if (isBuiltInCapability(capability)) return true // Built-ins are always "installed" return capability.services.every((service) => installedServices.some((s) => s.service_name === service) ) } // Check if a capability exists in the system (has at least one matching service) const capabilityExists = (capability: Capability) => { if (isBuiltInCapability(capability)) return true // Built-ins always exist return capability.services.some((service) => allServices.some((s) => s.service_name === service) ) } // Toggle all services for a capability (only if not already installed) const toggleCapability = (capability: Capability) => { // Don't allow toggling installed capabilities if (isCapabilityInstalled(capability)) return const isSelected = isCapabilitySelected(capability) if (isSelected) { // Deselect all services in this capability setSelectedServices((prev) => prev.filter((s) => !capability.services.includes(s))) } else { // Select all available services in this capability const servicesToAdd = capability.services.filter((service) => availableServices.some((s) => s.service_name === service) ) setSelectedServices((prev) => [...new Set([...prev, ...servicesToAdd])]) } } const renderCapabilityCard = (capability: Capability, isCore: boolean = true) => { const selected = isCapabilitySelected(capability) const installed = isCapabilityInstalled(capability) const exists = capabilityExists(capability) const isBuiltIn = isBuiltInCapability(capability) if (!exists) return null // Determine visual state: installed (locked), selected (user chose it), or default const isChecked = installed || selected // Handle click - built-in features navigate to their page, others toggle selection const handleClick = () => { if (isBuiltIn) { // Navigate to the appropriate settings page for built-in features if (capability.id === 'benchmark') { router.visit('/settings/benchmark') } } else { toggleCapability(capability) } } return (

{capability.name}

{isBuiltIn ? ( Built-in ) : installed && ( Installed )}

{isBuiltIn ? 'Click to open' : `Powered by ${capability.technicalName}`}

{capability.description}

{isCore && (
    {capability.features.map((feature, idx) => (
  • {feature}
  • ))}
)}
{isBuiltIn ? ( ) : isChecked && ( )}
) } const renderStep1 = () => { // Show all capabilities that exist in the system (including installed ones) const existingCoreCapabilities = CORE_CAPABILITIES.filter(capabilityExists) const existingAdditionalTools = ADDITIONAL_TOOLS.filter(capabilityExists) // Check if ALL capabilities are already installed (nothing left to install) const allCoreInstalled = existingCoreCapabilities.every(isCapabilityInstalled) const allAdditionalInstalled = existingAdditionalTools.every(isCapabilityInstalled) const allInstalled = allCoreInstalled && allAdditionalInstalled && existingCoreCapabilities.length > 0 return (

What do you want NOMAD to do?

Select the capabilities you need. You can always add more later.

{allInstalled ? (

All available capabilities are already installed!

router.visit('/settings/apps')} > Manage Apps
) : ( <> {/* Core Capabilities */} {existingCoreCapabilities.length > 0 && (

Core Capabilities

{existingCoreCapabilities.map((capability) => renderCapabilityCard(capability, true) )}
)} {/* Additional Tools - Collapsible */} {existingAdditionalTools.length > 0 && (
{showAdditionalTools && (
{existingAdditionalTools.map((capability) => renderCapabilityCard(capability, false) )}
)}
)} )}
) } const renderStep2 = () => (

Choose Map Regions

Select map region collections to download for offline use. You can always download more regions later.

{isLoadingMaps ? (
) : mapCollections && mapCollections.length > 0 ? (
{mapCollections.map((collection) => (
isOnline && !collection.all_downloaded && toggleMapCollection(collection.slug) } className={classNames( 'relative', selectedMapCollections.includes(collection.slug) && 'ring-4 ring-desert-green rounded-lg', collection.all_downloaded && 'opacity-75', !isOnline && 'opacity-50 cursor-not-allowed' )} > {selectedMapCollections.includes(collection.slug) && (
)}
))}
) : (

No map collections available at this time.

)}
) const renderStep3 = () => { // Check if AI or Information capabilities are selected OR already installed const isAiSelected = selectedServices.includes('nomad_open_webui') || installedServices.some((s) => s.service_name === 'nomad_open_webui') const isInformationSelected = selectedServices.includes('nomad_kiwix_serve') || installedServices.some((s) => s.service_name === 'nomad_kiwix_serve') return (

Choose Content

{isAiSelected && isInformationSelected ? 'Select AI models and content categories for offline use.' : isAiSelected ? 'Select AI models to download for offline use.' : isInformationSelected ? 'Select content categories for offline knowledge.' : 'Configure content for your selected capabilities.'}

{/* AI Model Selection - Only show if AI capability is selected */} {isAiSelected && (

Choose AI Models

Select AI models to download. We've recommended some smaller, popular models to get you started. You'll need at least one to use AI features, but you can always add more later.

{isLoadingRecommendedModels ? (
) : recommendedModels && recommendedModels.length > 0 ? (
{recommendedModels.map((model) => (
isOnline && toggleAiModel(model.name)} className={classNames( 'p-4 rounded-lg border-2 transition-all cursor-pointer', selectedAiModels.includes(model.name) ? 'border-desert-green bg-desert-green shadow-md' : 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm', !isOnline && 'opacity-50 cursor-not-allowed' )} >

{model.name}

{model.description}

{model.tags?.[0]?.size && (
Size: {model.tags[0].size}
)}
{selectedAiModels.includes(model.name) && ( )}
))}
) : (

No recommended AI models available at this time.

)}
)} {/* Curated Categories with Tiers - Only show if Information capability is selected */} {isInformationSelected && ( <> {isLoadingCategories ? (
) : categories && categories.length > 0 ? ( <>
{categories.map((category) => ( ))}
{/* Tier Selection Modal */} ) : null} {/* Legacy flat collections - show if available and no categories */} {(!categories || categories.length === 0) && ( <> {isLoadingZims ? (
) : zimCollections && zimCollections.length > 0 ? (
{zimCollections.map((collection) => (
isOnline && !collection.all_downloaded && toggleZimCollection(collection.slug) } className={classNames( 'relative', selectedZimCollections.includes(collection.slug) && 'ring-4 ring-desert-green rounded-lg', collection.all_downloaded && 'opacity-75', !isOnline && 'opacity-50 cursor-not-allowed' )} > {selectedZimCollections.includes(collection.slug) && (
)}
))}
) : (

No content collections available at this time.

)} )} )} {/* Show message if no capabilities requiring content are selected */} {!isAiSelected && !isInformationSelected && (

No content-based capabilities selected. You can skip this step or go back to select capabilities that require content.

)}
) } const renderStep4 = () => { const hasSelections = selectedServices.length > 0 || selectedMapCollections.length > 0 || selectedZimCollections.length > 0 || selectedTiers.size > 0 || selectedAiModels.length > 0 return (

Review Your Selections

Review your choices before starting the setup process.

{!hasSelections ? ( ) : (
{selectedServices.length > 0 && (

Capabilities to Install

    {[...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS] .filter((cap) => cap.services.some((s) => selectedServices.includes(s))) .map((capability) => (
  • {capability.name} ({capability.technicalName})
  • ))}
)} {selectedMapCollections.length > 0 && (

Map Collections to Download ({selectedMapCollections.length})

    {selectedMapCollections.map((slug) => { const collection = mapCollections?.find((c) => c.slug === slug) return (
  • {collection?.name || slug}
  • ) })}
)} {selectedZimCollections.length > 0 && (

ZIM Collections to Download ({selectedZimCollections.length})

    {selectedZimCollections.map((slug) => { const collection = zimCollections?.find((c) => c.slug === slug) return (
  • {collection?.name || slug}
  • ) })}
)} {selectedTiers.size > 0 && (

Content Categories ({selectedTiers.size})

{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => { const category = categories?.find((c) => c.slug === categorySlug) if (!category) return null const resources = getAllResourcesForTier(tier, category.tiers) return (
{category.name} - {tier.name} ({resources.length} files)
    {resources.map((resource, idx) => (
  • {resource.title}
  • ))}
) })}
)} {selectedAiModels.length > 0 && (

AI Models to Download ({selectedAiModels.length})

    {selectedAiModels.map((modelName) => { const model = recommendedModels?.find((m) => m.name === modelName) return (
  • {modelName}
    {model?.tags?.[0]?.size && ( {model.tags[0].size} )}
  • ) })}
)}
)}
) } return ( {!isOnline && ( )}
{renderStepIndicator()} {storageInfo && (
)}
{currentStep === 1 && renderStep1()} {currentStep === 2 && renderStep2()} {currentStep === 3 && renderStep3()} {currentStep === 4 && renderStep4()}
{currentStep > 1 && ( Back )}

{(() => { const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) => cap.services.some((s) => selectedServices.includes(s)) ).length return `${count} ${count === 1 ? 'capability' : 'capabilities'}` })()} , {selectedMapCollections.length} map region {selectedMapCollections.length !== 1 && 's'}, {selectedZimCollections.length}{' '} content pack{selectedZimCollections.length !== 1 && 's'},{' '} {selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected

router.visit('/home')} disabled={isProcessing} variant="outline" > Cancel & Go to Home {currentStep < 4 ? ( Next ) : ( Complete Setup )}
) }