fix(easy-setup): let the wizard run offline instead of 500ing and blocking Next
An air-gapped host installed from an artifact bundle could not use Easy Setup at all, which defeated the point: the bundle had already put the app images on disk. Two independent faults. The Wikipedia catalog was the only one of the page's four catalogs that threw on a failed fetch rather than degrading, so GET /api/zim/wikipedia returned 500 offline and the UI showed "An internal error occurred". It now reads the manifest through CollectionManifestService like the curated categories, maps and creator packs already do — refresh when reachable, cached copy when not, empty list when neither. The second, laxer schema for the same remote file is deleted; having two is what let the offline path diverge in the first place. Navigation and completion were gated on a blanket !isOnline check. Stepping through the wizard needs nothing from the network, and installing an app whose image is already in the local Docker daemon works air-gapped because the install path skips the registry pull. The gate is now per-selection: - The page reports which services have a local image, so offline the capability cards offer exactly those and badge the rest "Needs internet" rather than queuing an install that would fail mid-pull. - Map regions, content tiers, creator packs, AI models and Wikipedia are genuinely remote, so those steps stay unselectable — but they now say why instead of leaving dead cards. - Complete Setup is blocked offline only when a selection actually needs a download, and names which ones. The offline decision logic is a pure module with unit tests. Two gaps found and documented rather than changed here: an AMD host still can't install Ollama offline (the install swaps to ollama/ollama:rocm, which --with-apps doesn't bundle), and remote Ollama can't be configured from the wizard offline since its checkbox lives behind the AI capability card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f70ee33e1d
commit
8c91436aa4
|
|
@ -1,3 +1,4 @@
|
|||
import { DockerService } from '#services/docker_service'
|
||||
import { SystemService } from '#services/system_service'
|
||||
import { ZimService } from '#services/zim_service'
|
||||
import { CollectionManifestService } from '#services/collection_manifest_service'
|
||||
|
|
@ -9,18 +10,33 @@ import type { HttpContext } from '@adonisjs/core/http'
|
|||
export default class EasySetupController {
|
||||
constructor(
|
||||
private systemService: SystemService,
|
||||
private zimService: ZimService
|
||||
private zimService: ZimService,
|
||||
private dockerService: DockerService
|
||||
) {}
|
||||
|
||||
async index({ inertia }: HttpContext) {
|
||||
const [services, remoteOllamaUrl] = await Promise.all([
|
||||
const [services, remoteOllamaUrl, localImageTags] = await Promise.all([
|
||||
this.systemService.getServices({ installedOnly: false }),
|
||||
KVStore.getValue('ai.remoteOllamaUrl'),
|
||||
this.dockerService.listLocalImageTags(),
|
||||
])
|
||||
|
||||
// Apps whose image is already in the local Docker daemon install without
|
||||
// touching a registry (createContainerPreflight skips the pull when the
|
||||
// image is present). An offline artifact bundle built with --with-apps
|
||||
// loads exactly these, so this is what makes the wizard's offline mode
|
||||
// honest: it can say which capabilities are genuinely installable now
|
||||
// instead of pulling and failing halfway.
|
||||
const localImages = new Set(localImageTags)
|
||||
const locallyAvailableServices = services
|
||||
.filter((service) => !!service.container_image && localImages.has(service.container_image))
|
||||
.map((service) => service.service_name)
|
||||
|
||||
return inertia.render('easy-setup/index', {
|
||||
system: {
|
||||
services: services,
|
||||
remoteOllamaUrl: remoteOllamaUrl ?? '',
|
||||
locallyAvailableServices,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2301,21 +2301,32 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every image tag present in the local Docker daemon.
|
||||
*
|
||||
* Exposed so callers that need to test several images at once (the Easy Setup
|
||||
* wizard asking which apps are installable with no internet) can do it with a
|
||||
* single daemon round-trip instead of one per image. Returns an empty list if
|
||||
* the daemon can't be reached, which callers read as "nothing is local" —
|
||||
* the same fail-safe direction as `_checkImageExists`.
|
||||
*/
|
||||
async listLocalImageTags(): Promise<string[]> {
|
||||
try {
|
||||
const images = await this.docker.listImages()
|
||||
return images.flatMap((image) => image.RepoTags ?? [])
|
||||
} catch (error: any) {
|
||||
logger.warn(`Error listing local Docker images: ${error.message}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Docker image exists locally.
|
||||
* @param imageName - The name and tag of the image (e.g., "nginx:latest")
|
||||
* @returns - True if the image exists locally, false otherwise
|
||||
*/
|
||||
private async _checkImageExists(imageName: string): Promise<boolean> {
|
||||
try {
|
||||
const images = await this.docker.listImages()
|
||||
|
||||
// Check if any image has a RepoTag that matches the requested image
|
||||
return images.some((image) => image.RepoTags && image.RepoTags.includes(imageName))
|
||||
} catch (error: any) {
|
||||
logger.warn(`Error checking if image exists: ${error.message}`)
|
||||
// If run into an error, assume the image does not exist
|
||||
return false
|
||||
}
|
||||
const tags = await this.listLocalImageTags()
|
||||
return tags.includes(imageName)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ import {
|
|||
} from '../utils/fs.js'
|
||||
import { join, resolve, sep } from 'path'
|
||||
import { WikipediaOption, WikipediaState } from '../../types/downloads.js'
|
||||
import vine from '@vinejs/vine'
|
||||
import { wikipediaOptionsFileSchema } from '#validators/curated_collections'
|
||||
import WikipediaSelection from '#models/wikipedia_selection'
|
||||
import InstalledResource from '#models/installed_resource'
|
||||
import CollectionManifest from '#models/collection_manifest'
|
||||
|
|
@ -33,14 +31,13 @@ import { SERVICE_NAMES } from '../../constants/service_names.js'
|
|||
import { CollectionManifestService } from './collection_manifest_service.js'
|
||||
import { KiwixCatalogService } from './kiwix_catalog_service.js'
|
||||
import { KiwixLibraryService } from './kiwix_library_service.js'
|
||||
import type { CategoryWithStatus } from '../../types/collections.js'
|
||||
import type { CategoryWithStatus, WikipediaSpec } from '../../types/collections.js'
|
||||
import CustomLibrarySource from '#models/custom_library_source'
|
||||
import { assertNotPrivateUrl } from '#validators/common'
|
||||
import { resolveZimDownload } from '../utils/zim_download_resolution.js'
|
||||
import { getHostedContentHeaders } from '../utils/hosted_content_auth.js'
|
||||
|
||||
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
||||
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json'
|
||||
|
||||
@inject()
|
||||
export class ZimService {
|
||||
|
|
@ -693,21 +690,28 @@ export class ZimService {
|
|||
|
||||
// Wikipedia selector methods
|
||||
|
||||
/**
|
||||
* The Wikipedia catalog, refreshed from the remote manifest when reachable and
|
||||
* served from the cached copy when it isn't.
|
||||
*
|
||||
* This used to fetch the manifest directly and throw on any failure, which
|
||||
* turned `GET /api/zim/wikipedia` into a 500 on an air-gapped host and put an
|
||||
* "internal error" toast on the Easy Setup wizard. Routing through
|
||||
* CollectionManifestService reuses the same cached-spec fallback the curated
|
||||
* categories, maps and creator packs already rely on. An air-gapped host that
|
||||
* has never cached the manifest gets an empty list — the wizard renders no
|
||||
* Wikipedia selector rather than failing.
|
||||
*/
|
||||
async getWikipediaOptions(): Promise<WikipediaOption[]> {
|
||||
try {
|
||||
const response = await axios.get(WIKIPEDIA_OPTIONS_URL)
|
||||
const data = response.data
|
||||
|
||||
const validated = await vine.validate({
|
||||
schema: wikipediaOptionsFileSchema,
|
||||
data,
|
||||
})
|
||||
|
||||
return validated.options
|
||||
} catch (error) {
|
||||
logger.error(`[ZimService] Failed to fetch Wikipedia options:`, error)
|
||||
throw new Error('Failed to fetch Wikipedia options')
|
||||
const manifestService = new CollectionManifestService()
|
||||
const spec = await manifestService.getSpecWithFallback<WikipediaSpec>('wikipedia')
|
||||
if (!spec) {
|
||||
logger.warn(
|
||||
'[ZimService] No Wikipedia manifest available (remote unreachable and nothing cached)'
|
||||
)
|
||||
return []
|
||||
}
|
||||
return spec.options
|
||||
}
|
||||
|
||||
async getWikipediaSelection(): Promise<WikipediaSelection | null> {
|
||||
|
|
|
|||
|
|
@ -101,16 +101,9 @@ export const creatorPacksSpecSchema = vine.object({
|
|||
).minLength(1),
|
||||
})
|
||||
|
||||
// ---- Wikipedia validators (used by ZimService) ----
|
||||
|
||||
export const wikipediaOptionSchema = vine.object({
|
||||
id: vine.string(),
|
||||
name: vine.string(),
|
||||
description: vine.string(),
|
||||
size_mb: vine.number().min(0),
|
||||
url: vine.string().url().nullable(),
|
||||
})
|
||||
|
||||
export const wikipediaOptionsFileSchema = vine.object({
|
||||
options: vine.array(wikipediaOptionSchema).minLength(1),
|
||||
})
|
||||
// The Wikipedia catalog had a second, laxer schema here that ZimService used to
|
||||
// validate its own direct fetch of collections/wikipedia.json. Having two
|
||||
// schemas for one remote file is what let the offline path diverge: that fetch
|
||||
// had no cache fallback, so an air-gapped host 500'd the Wikipedia endpoint.
|
||||
// ZimService now reads the manifest through CollectionManifestService like every
|
||||
// other catalog, and `wikipediaSpecSchema` above is the only schema for it.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ behaves exactly as it always has.
|
|||
- [How artifact mode stays offline](#how-artifact-mode-stays-offline)
|
||||
- [GPU support](#gpu-support)
|
||||
- [Bundling Supply Depot apps](#bundling-supply-depot-apps)
|
||||
- [Easy Setup with no internet](#easy-setup-with-no-internet)
|
||||
- [Updating an air-gapped installation](#updating-an-air-gapped-installation)
|
||||
- [Optional images and pre-staged content](#optional-images-and-pre-staged-content)
|
||||
- [Known limitations](#known-limitations)
|
||||
|
|
@ -383,11 +384,46 @@ Most apps need nothing beyond their image. Two exceptions are worth knowing:
|
|||
separately from the Ollama registry at first use. Bundling the image does not
|
||||
make model downloads work offline.
|
||||
|
||||
On a host with an **AMD GPU** the installer switches Ollama to the ROCm image
|
||||
(`ollama/ollama:rocm`), which `--with-apps` does not carry — it bundles the
|
||||
catalog's pinned tag. An air-gapped AMD host therefore needs that tag added
|
||||
explicitly with `--extra-image-list`, or AMD acceleration turned off (KV key
|
||||
`ai.amdGpuAcceleration` set to `false`) so the bundled CPU image is used.
|
||||
|
||||
Content that is fetched from remote catalogs at runtime — ZIM files beyond the
|
||||
bundled sample, maps, Kolibri channels, AI models — is not covered. Use
|
||||
`--content-dir` to pre-stage such files if you already have them in NOMAD's
|
||||
storage layout.
|
||||
|
||||
## Easy Setup with no internet
|
||||
|
||||
The Command Center's Easy Setup wizard is the first thing an operator sees after
|
||||
an install, and it works air-gapped — for the part of it that can.
|
||||
|
||||
**Apps install offline when their image is already on the machine.** The wizard
|
||||
asks Docker which images are present and offers exactly those. An app whose
|
||||
image came from a `--with-apps` bundle is selectable and installs without
|
||||
touching a registry; one that would need a pull is shown greyed out with a
|
||||
*Needs internet* badge, so nothing fails halfway through.
|
||||
|
||||
**Content downloads stay unavailable.** Map regions, curated content tiers,
|
||||
creator packs, AI models and Wikipedia all come from remote catalogs. Offline,
|
||||
those steps explain themselves and their cards can't be selected. You can walk
|
||||
through every step, skip them, and finish the wizard with just the apps.
|
||||
|
||||
If the wizard is offline and *no* app images are on the machine, it says so
|
||||
plainly rather than offering a set of installs that would all fail. That is the
|
||||
signal to re-run the installer against a bundle built with `--with-apps`.
|
||||
|
||||
Two consequences of a truly air-gapped first boot are worth knowing:
|
||||
|
||||
- The wizard shows **no Wikipedia selector**. The catalog that populates it is
|
||||
fetched from GitHub and cached; a machine that has never been online has no
|
||||
cached copy, so the section is omitted. It appears on the Content step (and in
|
||||
the ZIM manager) the first time the machine reaches the internet.
|
||||
- The Kiwix app still needs at least one ZIM to start. A bundle that includes
|
||||
Kiwix pre-stages the small Wikipedia sample for exactly this reason.
|
||||
|
||||
## Updating an air-gapped installation
|
||||
|
||||
Re-running the installer with a newer bundle updates in place. This is the
|
||||
|
|
@ -473,8 +509,12 @@ Be precise about what offline artifact mode does and does not claim:
|
|||
- **One bundle, one platform.** OS, version and architecture must match.
|
||||
- **x86_64 only**, matching the installer's supported architecture.
|
||||
- **The Command Center still probes for connectivity at runtime.** It shows
|
||||
an offline status and falls back to bundled data rather than failing, but
|
||||
those requests are attempted. This is existing behaviour, unchanged here.
|
||||
an offline status and falls back to cached or bundled data rather than
|
||||
failing, but those requests are attempted.
|
||||
- **Easy Setup offline installs apps, not content.** See
|
||||
[Easy Setup with no internet](#easy-setup-with-no-internet). Only apps whose
|
||||
images are already on the machine are offered; every download-backed step is
|
||||
disabled with an explanation.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* What the Easy Setup wizard can and cannot do with no internet connection.
|
||||
*
|
||||
* NOMAD can be installed air-gapped from an offline artifact bundle (see
|
||||
* docs/offline-install.md). A bundle built with `--with-apps` carries the app
|
||||
* images, so the Command Center really can install those capabilities with the
|
||||
* cable pulled — the install path skips the registry pull whenever the image is
|
||||
* already in the local Docker daemon. Everything else the wizard offers (map
|
||||
* regions, curated content tiers, creator packs, AI models, Wikipedia) is
|
||||
* fetched from a remote catalog at run time and genuinely cannot work offline.
|
||||
*
|
||||
* The wizard used to draw no distinction: a single `!isOnline` check disabled
|
||||
* Next and Complete Setup, so an air-gapped operator could not install the
|
||||
* capabilities their bundle had already put on disk. These helpers split the
|
||||
* two cases so the UI can allow the first and explain the second.
|
||||
*
|
||||
* Pure functions, no React — unit-tested in tests/unit/offline_setup.spec.ts.
|
||||
*/
|
||||
|
||||
/** The kinds of selection the review step can be carrying. */
|
||||
export type OfflineBlocker =
|
||||
| 'services'
|
||||
| 'maps'
|
||||
| 'content'
|
||||
| 'creator-packs'
|
||||
| 'ai-models'
|
||||
| 'wikipedia'
|
||||
|
||||
export type WizardSelections = {
|
||||
/** service_name values queued for install. */
|
||||
services: string[]
|
||||
mapCollections: string[]
|
||||
creatorPacks: string[]
|
||||
/** Number of curated category tiers picked (selectedTiers.size). */
|
||||
categoryTierCount: number
|
||||
aiModels: string[]
|
||||
/** Wikipedia option id, or null when untouched. 'none' means "remove/skip". */
|
||||
wikipediaOptionId: string | null
|
||||
}
|
||||
|
||||
/** Human-readable reason shown next to the disabled Complete Setup button. */
|
||||
export const OFFLINE_BLOCKER_LABELS: Record<OfflineBlocker, string> = {
|
||||
services: 'apps whose image is not on this machine',
|
||||
maps: 'map regions',
|
||||
content: 'content categories',
|
||||
'creator-packs': 'creator packs',
|
||||
'ai-models': 'AI models',
|
||||
wikipedia: 'Wikipedia',
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this app can be installed with no internet: its image is already in
|
||||
* the local Docker daemon (loaded from an offline bundle, or left behind by a
|
||||
* previous install/uninstall), so the install skips the registry pull.
|
||||
*/
|
||||
export function isServiceInstallableOffline(
|
||||
serviceName: string,
|
||||
locallyAvailableServices: string[]
|
||||
): boolean {
|
||||
return locallyAvailableServices.includes(serviceName)
|
||||
}
|
||||
|
||||
/**
|
||||
* The selections that would need to reach the internet to complete. Empty means
|
||||
* the whole setup can run air-gapped.
|
||||
*
|
||||
* A Wikipedia pick of 'none' is a local deletion, not a download, so it never
|
||||
* blocks. Order is stable (declaration order) so the message reads the same way
|
||||
* every time.
|
||||
*/
|
||||
export function offlineBlockers(
|
||||
selections: WizardSelections,
|
||||
locallyAvailableServices: string[]
|
||||
): OfflineBlocker[] {
|
||||
const blockers: OfflineBlocker[] = []
|
||||
|
||||
const needsPull = selections.services.some(
|
||||
(service) => !isServiceInstallableOffline(service, locallyAvailableServices)
|
||||
)
|
||||
if (needsPull) blockers.push('services')
|
||||
if (selections.mapCollections.length > 0) blockers.push('maps')
|
||||
if (selections.categoryTierCount > 0) blockers.push('content')
|
||||
if (selections.creatorPacks.length > 0) blockers.push('creator-packs')
|
||||
if (selections.aiModels.length > 0) blockers.push('ai-models')
|
||||
if (selections.wikipediaOptionId !== null && selections.wikipediaOptionId !== 'none') {
|
||||
blockers.push('wikipedia')
|
||||
}
|
||||
|
||||
return blockers
|
||||
}
|
||||
|
||||
/** True when every selection can be carried out with no internet connection. */
|
||||
export function canCompleteSetupOffline(
|
||||
selections: WizardSelections,
|
||||
locallyAvailableServices: string[]
|
||||
): boolean {
|
||||
return offlineBlockers(selections, locallyAvailableServices).length === 0
|
||||
}
|
||||
|
||||
/** "map regions and AI models" — for the message explaining a blocked finish. */
|
||||
export function describeOfflineBlockers(blockers: OfflineBlocker[]): string {
|
||||
const labels = blockers.map((blocker) => OFFLINE_BLOCKER_LABELS[blocker])
|
||||
if (labels.length === 0) return ''
|
||||
if (labels.length === 1) return labels[0]
|
||||
return `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`
|
||||
}
|
||||
|
|
@ -22,6 +22,13 @@ import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData'
|
|||
import classNames from 'classnames'
|
||||
import type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections'
|
||||
import { resolveTierResources } from '~/lib/collections'
|
||||
import {
|
||||
canCompleteSetupOffline,
|
||||
describeOfflineBlockers,
|
||||
isServiceInstallableOffline,
|
||||
offlineBlockers,
|
||||
type WizardSelections,
|
||||
} from '~/lib/offline_setup'
|
||||
import { SERVICE_NAMES } from '../../../constants/service_names'
|
||||
|
||||
// Capability definitions - maps user-friendly categories to services
|
||||
|
|
@ -107,7 +114,17 @@ const CURATED_CATEGORIES_KEY = 'curated-categories'
|
|||
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
|
||||
|
||||
export default function EasySetupWizard(props: {
|
||||
system: { services: ServiceSlim[]; remoteOllamaUrl: string }
|
||||
system: {
|
||||
services: ServiceSlim[]
|
||||
remoteOllamaUrl: string
|
||||
/**
|
||||
* service_name values whose container image is already in the local Docker
|
||||
* daemon — the apps an air-gapped host can install right now, because the
|
||||
* install path skips the registry pull when the image is present. Populated
|
||||
* by an offline artifact bundle built with `--with-apps`.
|
||||
*/
|
||||
locallyAvailableServices?: string[]
|
||||
}
|
||||
}) {
|
||||
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
|
||||
const CORE_CAPABILITIES = buildCoreCapabilities(aiAssistantName)
|
||||
|
|
@ -351,13 +368,34 @@ export default function EasySetupWizard(props: {
|
|||
// Get primary disk/filesystem info for storage projection
|
||||
const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize)
|
||||
|
||||
// Offline capability (docs/offline-install.md). An air-gapped host installed
|
||||
// from an artifact bundle has the app images on disk already, so installing
|
||||
// those capabilities works with no internet; only the remote catalogs (maps,
|
||||
// content, packs, models, Wikipedia) genuinely need a connection. The wizard
|
||||
// used to gate *everything* on isOnline, which locked offline operators out
|
||||
// of the one thing their bundle had prepared for them.
|
||||
const locallyAvailableServices = props.system.locallyAvailableServices ?? []
|
||||
|
||||
const currentSelections: WizardSelections = {
|
||||
services: selectedServices,
|
||||
mapCollections: selectedMapCollections,
|
||||
creatorPacks: selectedCreatorPacks,
|
||||
categoryTierCount: selectedTiers.size,
|
||||
aiModels: selectedAiModels,
|
||||
wikipediaOptionId: selectedWikipedia,
|
||||
}
|
||||
|
||||
const offlineOnlyBlockers = offlineBlockers(currentSelections, locallyAvailableServices)
|
||||
const canFinishOffline = canCompleteSetupOffline(currentSelections, locallyAvailableServices)
|
||||
|
||||
// The review step is always the last active step. Read by canProceedToNextStep
|
||||
// and the bottom-bar Next-vs-Finish switch.
|
||||
const finalStep: WizardStep = activeSteps[activeSteps.length - 1]
|
||||
|
||||
const canProceedToNextStep = () => {
|
||||
if (!isOnline) return false // Must be online to proceed
|
||||
// Every step before the review is skippable; the review step shows Finish, not Next.
|
||||
// Navigation itself needs nothing from the network — the per-step controls
|
||||
// decide what an offline user may actually pick. The review step shows
|
||||
// Finish, not Next.
|
||||
return currentStep < finalStep
|
||||
}
|
||||
|
||||
|
|
@ -376,10 +414,12 @@ export default function EasySetupWizard(props: {
|
|||
}
|
||||
|
||||
const handleFinish = async () => {
|
||||
if (!isOnline) {
|
||||
// Offline is only a problem for the selections that have to fetch something.
|
||||
// Installing an app whose image is already local is fine air-gapped.
|
||||
if (!isOnline && !canFinishOffline) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: 'You must have an internet connection to complete the setup.',
|
||||
message: `Without an internet connection NOMAD can't set up ${describeOfflineBlockers(offlineOnlyBlockers)}. Remove those selections to continue offline.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -592,10 +632,22 @@ export default function EasySetupWizard(props: {
|
|||
)
|
||||
}
|
||||
|
||||
// Offline, a capability is installable only if every image it needs is
|
||||
// already in the local Docker daemon — otherwise the install would try to
|
||||
// pull and fail. Online this is always true; the pull just happens.
|
||||
const isCapabilityAvailable = (capability: Capability) => {
|
||||
if (isOnline) return true
|
||||
return capability.services.every((service) =>
|
||||
isServiceInstallableOffline(service, locallyAvailableServices)
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
// Offline with no local image there is nothing to install from.
|
||||
if (!isCapabilityAvailable(capability)) return
|
||||
|
||||
const isSelected = isCapabilitySelected(capability)
|
||||
|
||||
|
|
@ -637,6 +689,7 @@ export default function EasySetupWizard(props: {
|
|||
const selected = isCapabilitySelected(capability)
|
||||
const installed = isCapabilityInstalled(capability)
|
||||
const exists = capabilityExists(capability)
|
||||
const unavailableOffline = !installed && !isCapabilityAvailable(capability)
|
||||
|
||||
if (!exists) return null
|
||||
|
||||
|
|
@ -651,9 +704,11 @@ export default function EasySetupWizard(props: {
|
|||
'p-6 rounded-lg border-2 transition-all',
|
||||
installed
|
||||
? 'border-desert-green bg-desert-green/20 cursor-default'
|
||||
: selected
|
||||
? 'border-desert-green bg-desert-green shadow-md cursor-pointer'
|
||||
: 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm cursor-pointer'
|
||||
: unavailableOffline
|
||||
? 'border-desert-stone-light bg-surface-primary opacity-50 cursor-not-allowed'
|
||||
: selected
|
||||
? 'border-desert-green bg-desert-green shadow-md cursor-pointer'
|
||||
: 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
|
|
@ -672,6 +727,11 @@ export default function EasySetupWizard(props: {
|
|||
Installed
|
||||
</span>
|
||||
)}
|
||||
{unavailableOffline && (
|
||||
<span className="text-xs bg-surface-secondary text-text-muted border border-border-default px-2 py-0.5 rounded-full">
|
||||
Needs internet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={classNames(
|
||||
|
|
@ -853,6 +913,19 @@ export default function EasySetupWizard(props: {
|
|||
)
|
||||
}
|
||||
|
||||
// Steps 2-5 all hand out remote content. Offline they stay browsable but
|
||||
// unselectable, so say why rather than leaving the user clicking dead cards.
|
||||
const renderOfflineDownloadNotice = (what: string) =>
|
||||
!isOnline ? (
|
||||
<Alert
|
||||
title="Unavailable offline"
|
||||
message={`${what} download from a remote catalog, so they can't be added while this machine has no internet connection. Skip this step — apps whose images are already on disk still install — and come back here once you're connected.`}
|
||||
type="info"
|
||||
variant="bordered"
|
||||
className="mb-6"
|
||||
/>
|
||||
) : null
|
||||
|
||||
const renderStep2 = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-6">
|
||||
|
|
@ -862,6 +935,7 @@ export default function EasySetupWizard(props: {
|
|||
regions later.
|
||||
</p>
|
||||
</div>
|
||||
{renderOfflineDownloadNotice('Map regions')}
|
||||
<div className="mx-auto max-w-2xl rounded-lg border border-border-subtle bg-surface-secondary p-3 text-center">
|
||||
<p className="text-sm text-text-secondary">
|
||||
Only need a specific country, or want the whole world? Individual countries and a full
|
||||
|
|
@ -932,6 +1006,8 @@ export default function EasySetupWizard(props: {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{renderOfflineDownloadNotice('Wikipedia and curated content collections')}
|
||||
|
||||
{/* Wikipedia Selection - Only show if Information capability is selected */}
|
||||
{isInformationSelected && (
|
||||
<div className="mb-8">
|
||||
|
|
@ -1028,6 +1104,8 @@ export default function EasySetupWizard(props: {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{renderOfflineDownloadNotice('Creator packs')}
|
||||
|
||||
{creatorPacks.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{creatorPacks.map((pack) => (
|
||||
|
|
@ -1064,6 +1142,8 @@ export default function EasySetupWizard(props: {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{renderOfflineDownloadNotice('AI models')}
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
|
||||
<IconCpu className="w-6 h-6 text-text-primary" />
|
||||
|
|
@ -1379,12 +1459,25 @@ export default function EasySetupWizard(props: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
title="Ready to Start"
|
||||
message="Click 'Complete Setup' to begin installing apps and downloading content. This may take some time depending on your internet connection and the size of the downloads."
|
||||
type="info"
|
||||
variant="solid"
|
||||
/>
|
||||
{!isOnline && !canFinishOffline ? (
|
||||
<Alert
|
||||
title="Some selections need an internet connection"
|
||||
message={`This machine is offline, so NOMAD can't set up ${describeOfflineBlockers(offlineOnlyBlockers)}. Go back and clear those selections to finish the rest now, or connect to the internet and return here.`}
|
||||
type="warning"
|
||||
variant="solid"
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
title="Ready to Start"
|
||||
message={
|
||||
isOnline
|
||||
? "Click 'Complete Setup' to begin installing apps and downloading content. This may take some time depending on your internet connection and the size of the downloads."
|
||||
: "Click 'Complete Setup' to install the selected apps from images already on this machine. No internet connection is needed for these."
|
||||
}
|
||||
type="info"
|
||||
variant="solid"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1397,7 +1490,11 @@ export default function EasySetupWizard(props: {
|
|||
{!isOnline && (
|
||||
<Alert
|
||||
title="No Internet Connection"
|
||||
message="You'll need an internet connection to proceed. Please connect to the internet and try again."
|
||||
message={
|
||||
locallyAvailableServices.length > 0
|
||||
? 'You can still install apps whose images are already on this machine — an offline install bundle puts them there. Downloads (maps, content, creator packs, AI models, Wikipedia) need a connection and stay unavailable until you have one.'
|
||||
: "No app images are available locally, so there's nothing this wizard can install right now. Connect to the internet, or re-run the installer against an offline bundle built with --with-apps."
|
||||
}
|
||||
type="warning"
|
||||
variant="solid"
|
||||
className="mb-8"
|
||||
|
|
@ -1477,7 +1574,9 @@ export default function EasySetupWizard(props: {
|
|||
) : (
|
||||
<StyledButton
|
||||
onClick={handleFinish}
|
||||
disabled={isProcessing || !isOnline || !anySelectionMade}
|
||||
disabled={
|
||||
isProcessing || !anySelectionMade || (!isOnline && !canFinishOffline)
|
||||
}
|
||||
loading={isProcessing}
|
||||
variant="success"
|
||||
icon="IconCheck"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import * as assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
canCompleteSetupOffline,
|
||||
describeOfflineBlockers,
|
||||
isServiceInstallableOffline,
|
||||
offlineBlockers,
|
||||
} from '../../inertia/lib/offline_setup.js'
|
||||
import type { WizardSelections } from '../../inertia/lib/offline_setup.js'
|
||||
|
||||
const NOTHING_SELECTED: WizardSelections = {
|
||||
services: [],
|
||||
mapCollections: [],
|
||||
creatorPacks: [],
|
||||
categoryTierCount: 0,
|
||||
aiModels: [],
|
||||
wikipediaOptionId: null,
|
||||
}
|
||||
|
||||
const selections = (overrides: Partial<WizardSelections>): WizardSelections => ({
|
||||
...NOTHING_SELECTED,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
test('an app whose image is loaded locally installs offline', () => {
|
||||
assert.equal(isServiceInstallableOffline('kiwix', ['kiwix', 'kolibri']), true)
|
||||
assert.equal(isServiceInstallableOffline('ollama', ['kiwix', 'kolibri']), false)
|
||||
})
|
||||
|
||||
test('installing only locally-available apps is possible offline', () => {
|
||||
const picks = selections({ services: ['kiwix', 'kolibri'] })
|
||||
assert.deepEqual(offlineBlockers(picks, ['kiwix', 'kolibri', 'ollama']), [])
|
||||
assert.equal(canCompleteSetupOffline(picks, ['kiwix', 'kolibri', 'ollama']), true)
|
||||
})
|
||||
|
||||
test('an app with no local image blocks an offline finish', () => {
|
||||
const picks = selections({ services: ['kiwix', 'ollama'] })
|
||||
assert.deepEqual(offlineBlockers(picks, ['kiwix']), ['services'])
|
||||
assert.equal(canCompleteSetupOffline(picks, ['kiwix']), false)
|
||||
})
|
||||
|
||||
test('every remote-catalog selection blocks an offline finish', () => {
|
||||
assert.deepEqual(offlineBlockers(selections({ mapCollections: ['north-america'] }), []), ['maps'])
|
||||
assert.deepEqual(offlineBlockers(selections({ categoryTierCount: 1 }), []), ['content'])
|
||||
assert.deepEqual(offlineBlockers(selections({ creatorPacks: ['pack-a'] }), []), ['creator-packs'])
|
||||
assert.deepEqual(offlineBlockers(selections({ aiModels: ['llama3'] }), []), ['ai-models'])
|
||||
assert.deepEqual(offlineBlockers(selections({ wikipediaOptionId: 'full' }), []), ['wikipedia'])
|
||||
})
|
||||
|
||||
test("Wikipedia 'none' is a local deletion, not a download", () => {
|
||||
assert.deepEqual(offlineBlockers(selections({ wikipediaOptionId: 'none' }), []), [])
|
||||
assert.equal(canCompleteSetupOffline(selections({ wikipediaOptionId: 'none' }), []), true)
|
||||
})
|
||||
|
||||
test('an empty wizard is trivially completable offline', () => {
|
||||
assert.equal(canCompleteSetupOffline(NOTHING_SELECTED, []), true)
|
||||
})
|
||||
|
||||
test('blockers are reported in a stable order', () => {
|
||||
const picks = selections({
|
||||
services: ['ollama'],
|
||||
mapCollections: ['north-america'],
|
||||
categoryTierCount: 2,
|
||||
creatorPacks: ['pack-a'],
|
||||
aiModels: ['llama3'],
|
||||
wikipediaOptionId: 'full',
|
||||
})
|
||||
assert.deepEqual(offlineBlockers(picks, []), [
|
||||
'services',
|
||||
'maps',
|
||||
'content',
|
||||
'creator-packs',
|
||||
'ai-models',
|
||||
'wikipedia',
|
||||
])
|
||||
})
|
||||
|
||||
test('blocker descriptions read as a sentence fragment', () => {
|
||||
assert.equal(describeOfflineBlockers([]), '')
|
||||
assert.equal(describeOfflineBlockers(['maps']), 'map regions')
|
||||
assert.equal(describeOfflineBlockers(['maps', 'ai-models']), 'map regions and AI models')
|
||||
assert.equal(
|
||||
describeOfflineBlockers(['maps', 'content', 'ai-models']),
|
||||
'map regions, content categories and AI models'
|
||||
)
|
||||
})
|
||||
|
|
@ -88,10 +88,6 @@ export type WikipediaOption = {
|
|||
url: string | null
|
||||
}
|
||||
|
||||
export type WikipediaOptionsFile = {
|
||||
options: WikipediaOption[]
|
||||
}
|
||||
|
||||
export type WikipediaCurrentSelection = {
|
||||
optionId: string
|
||||
status: 'none' | 'downloading' | 'installed' | 'failed'
|
||||
|
|
|
|||
Loading…
Reference in New Issue