107 lines
4.0 KiB
TypeScript
107 lines
4.0 KiB
TypeScript
/**
|
|
* 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]}`
|
|
}
|