From 8c91436aa42d804dc090f988f218975839e28f80 Mon Sep 17 00:00:00 2001 From: Ken Eucker Date: Sat, 15 Aug 2026 19:38:13 -0700 Subject: [PATCH] fix(easy-setup): let the wizard run offline instead of 500ing and blocking Next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/controllers/easy_setup_controller.ts | 20 ++- admin/app/services/docker_service.ts | 31 +++-- admin/app/services/zim_service.ts | 38 ++--- admin/app/validators/curated_collections.ts | 19 +-- admin/docs/offline-install.md | 44 +++++- admin/inertia/lib/offline_setup.ts | 106 ++++++++++++++ admin/inertia/pages/easy-setup/index.tsx | 131 +++++++++++++++--- admin/tests/unit/offline_setup.spec.ts | 87 ++++++++++++ admin/types/downloads.ts | 4 - 9 files changed, 416 insertions(+), 64 deletions(-) create mode 100644 admin/inertia/lib/offline_setup.ts create mode 100644 admin/tests/unit/offline_setup.spec.ts diff --git a/admin/app/controllers/easy_setup_controller.ts b/admin/app/controllers/easy_setup_controller.ts index 34276f4..8d94c85 100644 --- a/admin/app/controllers/easy_setup_controller.ts +++ b/admin/app/controllers/easy_setup_controller.ts @@ -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, }, }) } diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 61bcec3..926270d 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -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 { + 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 { - 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) } } diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index c8a735c..4f4d064 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -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 { - 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('wikipedia') + if (!spec) { + logger.warn( + '[ZimService] No Wikipedia manifest available (remote unreachable and nothing cached)' + ) + return [] } + return spec.options } async getWikipediaSelection(): Promise { diff --git a/admin/app/validators/curated_collections.ts b/admin/app/validators/curated_collections.ts index 8d1ec58..07efeb8 100644 --- a/admin/app/validators/curated_collections.ts +++ b/admin/app/validators/curated_collections.ts @@ -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. diff --git a/admin/docs/offline-install.md b/admin/docs/offline-install.md index 68809c7..af49dc1 100644 --- a/admin/docs/offline-install.md +++ b/admin/docs/offline-install.md @@ -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. --- diff --git a/admin/inertia/lib/offline_setup.ts b/admin/inertia/lib/offline_setup.ts new file mode 100644 index 0000000..06ded87 --- /dev/null +++ b/admin/inertia/lib/offline_setup.ts @@ -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 = { + 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]}` +} diff --git a/admin/inertia/pages/easy-setup/index.tsx b/admin/inertia/pages/easy-setup/index.tsx index a8e743e..e24b4e0 100644 --- a/admin/inertia/pages/easy-setup/index.tsx +++ b/admin/inertia/pages/easy-setup/index.tsx @@ -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' )} >
@@ -672,6 +727,11 @@ export default function EasySetupWizard(props: { Installed )} + {unavailableOffline && ( + + Needs internet + + )}

+ !isOnline ? ( + + ) : null + const renderStep2 = () => (

@@ -862,6 +935,7 @@ export default function EasySetupWizard(props: { regions later.

+ {renderOfflineDownloadNotice('Map regions')}

Only need a specific country, or want the whole world? Individual countries and a full @@ -932,6 +1006,8 @@ export default function EasySetupWizard(props: {

+ {renderOfflineDownloadNotice('Wikipedia and curated content collections')} + {/* Wikipedia Selection - Only show if Information capability is selected */} {isInformationSelected && (
@@ -1028,6 +1104,8 @@ export default function EasySetupWizard(props: {

+ {renderOfflineDownloadNotice('Creator packs')} + {creatorPacks.length > 0 ? (
{creatorPacks.map((pack) => ( @@ -1064,6 +1142,8 @@ export default function EasySetupWizard(props: {

+ {renderOfflineDownloadNotice('AI models')} +
@@ -1379,12 +1459,25 @@ export default function EasySetupWizard(props: {
)} - + {!isOnline && !canFinishOffline ? ( + + ) : ( + + )}
)}
@@ -1397,7 +1490,11 @@ export default function EasySetupWizard(props: { {!isOnline && ( 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: { ) : ( ): 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' + ) +}) diff --git a/admin/types/downloads.ts b/admin/types/downloads.ts index 669df9e..648b31e 100644 --- a/admin/types/downloads.ts +++ b/admin/types/downloads.ts @@ -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'