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:
Ken Eucker 2026-08-15 19:38:13 -07:00
parent f70ee33e1d
commit 8c91436aa4
9 changed files with 416 additions and 64 deletions

View File

@ -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,
},
})
}

View File

@ -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)
}
}

View File

@ -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> {

View File

@ -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.

View File

@ -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.
---

View File

@ -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]}`
}

View File

@ -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"

View File

@ -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'
)
})

View File

@ -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'