From d13679f961c468f300ce055c755061c91d5633ef Mon Sep 17 00:00:00 2001 From: not-knope <102312680+not-knope@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:23:06 +0200 Subject: [PATCH] fix(maps): warn when world basemap missing instead of silent grey map An install that never went online while visiting /maps never provisions world.pmtiles (it is lazily extracted from the internet), leaving the map blank/grey offline outside any downloaded regional extracts. Detect the world basemap's presence on disk and surface it to the UI so: - /maps shows a clear notice explaining the base map isn't downloaded and how to fix it, instead of a silent grey screen. - /settings/maps offers an explicit "Download Base Map" action (~15 MB) to provision it deliberately while online. Adds MapService.checkWorldBasemapExists() / provisionWorldBasemap(), a POST /maps/setup-world-basemap endpoint, and threads worldBasemapExists through the maps page props. Refs #1030 Co-authored-by: Cursor --- admin/app/controllers/maps_controller.ts | 24 ++++++++++++- admin/app/controllers/settings_controller.ts | 6 +++- admin/app/services/map_service.ts | 28 +++++++++++++++ admin/inertia/lib/api.ts | 7 ++++ admin/inertia/pages/maps.tsx | 4 ++- admin/inertia/pages/settings/maps.tsx | 36 +++++++++++++++++++- admin/start/routes.ts | 1 + 7 files changed, 102 insertions(+), 4 deletions(-) diff --git a/admin/app/controllers/maps_controller.ts b/admin/app/controllers/maps_controller.ts index 097d9fb..6929e0a 100644 --- a/admin/app/controllers/maps_controller.ts +++ b/admin/app/controllers/maps_controller.ts @@ -19,10 +19,14 @@ export default class MapsController { async index({ inertia }: HttpContext) { const baseAssetsCheck = await this.mapService.ensureBaseAssets() - const regionFiles = await this.mapService.listRegions() + const [regionFiles, worldBasemapExists] = await Promise.all([ + this.mapService.listRegions(), + this.mapService.checkWorldBasemapExists(), + ]) return inertia.render('maps', { maps: { baseAssetsExist: baseAssetsCheck, + worldBasemapExists, regionFiles: regionFiles.files, }, }) @@ -35,6 +39,24 @@ export default class MapsController { return { success: true } } + async setupWorldBasemap({ response }: HttpContext) { + try { + const ready = await this.mapService.provisionWorldBasemap() + if (!ready) { + return response.status(500).send({ + message: + 'Could not download the base map. Please connect this NOMAD to the internet and try again.', + }) + } + return { success: true } + } catch { + return response.status(500).send({ + message: + 'Could not download the base map. Please connect this NOMAD to the internet and try again.', + }) + } + } + async downloadRemote({ request }: HttpContext) { const payload = await request.validateUsing(remoteDownloadValidator) assertNotPrivateUrl(payload.url) diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index 4675d4b..e6c7a6a 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -45,10 +45,14 @@ export default class SettingsController { async maps({ inertia }: HttpContext) { const baseAssetsCheck = await this.mapService.ensureBaseAssets() - const regionFiles = await this.mapService.listRegions() + const [regionFiles, worldBasemapExists] = await Promise.all([ + this.mapService.listRegions(), + this.mapService.checkWorldBasemapExists(), + ]) return inertia.render('settings/maps', { maps: { baseAssetsExist: baseAssetsCheck, + worldBasemapExists, regionFiles: regionFiles.files, }, }) diff --git a/admin/app/services/map_service.ts b/admin/app/services/map_service.ts index 7aa20a7..debc4c5 100644 --- a/admin/app/services/map_service.ts +++ b/admin/app/services/map_service.ts @@ -404,6 +404,34 @@ export class MapService implements IMapService { return true } + /** + * Whether the low-zoom world basemap is present on disk. Checked directly (rather than trusting + * the in-process `worldBasemapReady` flag) so callers get an accurate answer regardless of whether + * `ensureWorldBasemap()` has run yet this process. Used to warn the user instead of showing a + * silent grey map when the basemap was never provisioned (e.g. installed straight offline, #1030). + */ + async checkWorldBasemapExists(): Promise { + const basePath = resolve(join(this.baseDirPath, 'pmtiles')) + const filepath = resolve(join(basePath, WORLD_BASEMAP_FILENAME)) + if (!filepath.startsWith(basePath + sep)) return false + const stats = await getFileStatsIfExists(filepath) + const exists = !!stats && Number(stats.size) > 0 + if (exists) this.worldBasemapReady = true + return exists + } + + /** + * Explicitly (re)provision the world basemap on demand. Ensures base assets exist, then extracts + * the basemap if it isn't present yet. Requires internet — surfaced to the user as a "download the + * base map" action so the one network dependency can be satisfied deliberately while online (#1030). + */ + async provisionWorldBasemap(): Promise { + const baseAssetsExist = await this.ensureBaseAssets() + if (!baseAssetsExist) return false + await this.ensureWorldBasemap() + return this.checkWorldBasemapExists() + } + /** * Extract a low-zoom global basemap once so the map isn't grey outside a * regional extract's polygon. Cheap (~15 MB, a handful of HTTP range diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 9782e9e..ec16570 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -82,6 +82,13 @@ class API { })() } + async setupWorldBasemap() { + return catchInternal(async () => { + const response = await this.client.post<{ success: boolean }>('/maps/setup-world-basemap') + return response.data + })() + } + async downloadMapCollection(slug: string): Promise<{ message: string slug: string diff --git a/admin/inertia/pages/maps.tsx b/admin/inertia/pages/maps.tsx index a1d8df1..186da0b 100644 --- a/admin/inertia/pages/maps.tsx +++ b/admin/inertia/pages/maps.tsx @@ -10,13 +10,15 @@ import Alert from '~/components/Alert' import { FileEntry } from '../../types/files' export default function Maps(props: { - maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] } + maps: { baseAssetsExist: boolean; worldBasemapExists: boolean; regionFiles: FileEntry[] } }) { const [isHoveringUI, setIsHoveringUI] = useState(false) const [showMapCoordinates, setShowMapCoordinates] = useState(true) const alertMessage = !props.maps.baseAssetsExist ? 'The base map assets have not been installed. Please download them first to enable map functionality.' + : !props.maps.worldBasemapExists + ? 'The world base map has not been downloaded yet, so the map may appear blank outside downloaded regions. Connect this NOMAD to the internet and download it (~15 MB) from Map Settings.' : props.maps.regionFiles.length === 0 ? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.' : null diff --git a/admin/inertia/pages/settings/maps.tsx b/admin/inertia/pages/settings/maps.tsx index d2df0f3..1f4b01d 100644 --- a/admin/inertia/pages/settings/maps.tsx +++ b/admin/inertia/pages/settings/maps.tsx @@ -24,7 +24,7 @@ const CURATED_COLLECTIONS_KEY = 'curated-map-collections' const GLOBAL_MAP_INFO_KEY = 'global-map-info' export default function MapsManager(props: { - maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] } + maps: { baseAssetsExist: boolean; worldBasemapExists: boolean; regionFiles: FileEntry[] } }) { const queryClient = useQueryClient() const { openModal, closeAllModals } = useModals() @@ -61,6 +61,24 @@ export default function MapsManager(props: { }) const globalMapAlreadyDownloaded = hasDownloadedGlobalMap(globalMapInfo?.key, props.maps.regionFiles) + const setupWorldBasemap = useMutation({ + mutationFn: () => api.setupWorldBasemap(), + onSuccess: () => { + addNotification({ + type: 'success', + message: 'Base map downloaded successfully.', + }) + router.reload({ only: ['maps'] }) + }, + onError: () => { + addNotification({ + type: 'error', + message: + 'Could not download the base map. Please connect this NOMAD to the internet and try again.', + }) + }, + }) + const downloadGlobalMap = useMutation({ mutationFn: () => api.downloadGlobalMap(), onSuccess: () => { @@ -306,6 +324,22 @@ export default function MapsManager(props: { }} /> )} + {props.maps.baseAssetsExist && !props.maps.worldBasemapExists && ( + setupWorldBasemap.mutate(), + }} + /> + )} {globalMapInfo && globalMapAlreadyDownloaded && (