Skip to content

Commit 606dd3a

Browse files
committed
feat: [wip] custom map and zim downloads
1 parent dc2bae1 commit 606dd3a

File tree

18 files changed

+386
-85
lines changed

18 files changed

+386
-85
lines changed

admin/app/controllers/maps_controller.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ export default class MapsController {
3636
}
3737
}
3838

39+
// For providing a "preflight" check in the UI before actually starting a background download
40+
async downloadRemotePreflight({ request }: HttpContext) {
41+
const payload = await request.validateUsing(remoteDownloadValidator)
42+
console.log(payload)
43+
const info = await this.mapService.downloadRemotePreflight(payload.url)
44+
return info
45+
}
46+
3947
async listRegions({}: HttpContext) {
4048
return await this.mapService.listRegions()
4149
}

admin/app/controllers/zim_controller.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ZimService } from '#services/zim_service'
22
import { filenameValidator, remoteDownloadValidator } from '#validators/common'
3+
import { listRemoteZimValidator } from '#validators/zim'
34
import { inject } from '@adonisjs/core'
45
import type { HttpContext } from '@adonisjs/core/http'
56

@@ -12,8 +13,9 @@ export default class ZimController {
1213
}
1314

1415
async listRemote({ request }: HttpContext) {
15-
const { start = 0, count = 12 } = request.qs()
16-
return await this.zimService.listRemote({ start, count })
16+
const payload = await request.validateUsing(listRemoteZimValidator)
17+
const { start = 0, count = 12, query } = payload
18+
return await this.zimService.listRemote({ start, count, query })
1719
}
1820

1921
async downloadRemote({ request }: HttpContext) {
@@ -27,6 +29,10 @@ export default class ZimController {
2729
}
2830
}
2931

32+
async listActiveDownloads({}: HttpContext) {
33+
return this.zimService.listActiveDownloads()
34+
}
35+
3036
async delete({ request, response }: HttpContext) {
3137
const payload = await request.validateUsing(filenameValidator)
3238

admin/app/services/map_service.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '../utils/fs.js'
1313
import { join } from 'path'
1414
import urlJoin from 'url-join'
15+
import axios from 'axios'
1516

1617
const BASE_ASSETS_MIME_TYPES = [
1718
'application/gzip',
@@ -114,6 +115,36 @@ export class MapService {
114115
return filename
115116
}
116117

118+
async downloadRemotePreflight(
119+
url: string
120+
): Promise<{ filename: string; size: number } | { message: string }> {
121+
try {
122+
const parsed = new URL(url)
123+
if (!parsed.pathname.endsWith('.pmtiles')) {
124+
throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`)
125+
}
126+
127+
const filename = url.split('/').pop()
128+
if (!filename) {
129+
throw new Error('Could not determine filename from URL')
130+
}
131+
132+
// Perform a HEAD request to get the content length
133+
const response = await axios.head(url)
134+
135+
if (response.status !== 200) {
136+
throw new Error(`Failed to fetch file info: ${response.status} ${response.statusText}`)
137+
}
138+
139+
const contentLength = response.headers['content-length']
140+
const size = contentLength ? parseInt(contentLength, 10) : 0
141+
142+
return { filename, size }
143+
} catch (error) {
144+
return { message: `Preflight check failed: ${error.message}` }
145+
}
146+
}
147+
117148
async generateStylesJSON() {
118149
if (!(await this.checkBaseAssetsExist())) {
119150
throw new Error('Base map assets are missing from storage/maps')

admin/app/services/zim_service.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,11 @@ export class ZimService {
4444
async listRemote({
4545
start,
4646
count,
47+
query,
4748
}: {
4849
start: number
4950
count: number
51+
query?: string
5052
}): Promise<ListRemoteZimFilesResponse> {
5153
const LIBRARY_BASE_URL = 'https://browse.library.kiwix.org/catalog/v2/entries'
5254

@@ -55,6 +57,7 @@ export class ZimService {
5557
start: start,
5658
count: count,
5759
lang: 'eng',
60+
...(query ? { q: query } : {}),
5861
},
5962
responseType: 'text',
6063
})
@@ -71,7 +74,8 @@ export class ZimService {
7174
throw new Error('Invalid response format from remote library')
7275
}
7376

74-
const filtered = result.feed.entry.filter((entry: any) => {
77+
const entries = Array.isArray(result.feed.entry) ? result.feed.entry : [result.feed.entry]
78+
const filtered = entries.filter((entry: any) => {
7579
return isRawRemoteZimFileEntry(entry)
7680
})
7781

@@ -166,7 +170,7 @@ export class ZimService {
166170
return filename
167171
}
168172

169-
getActiveDownloads(): string[] {
173+
listActiveDownloads(): string[] {
170174
return Array.from(this.activeDownloads.keys())
171175
}
172176

admin/app/validators/common.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ import vine from '@vinejs/vine'
22

33
export const remoteDownloadValidator = vine.compile(
44
vine.object({
5-
url: vine.string().url().trim(),
5+
url: vine.string().url({
6+
require_tld: false, // Allow local URLs
7+
}).trim(),
68
})
79
)
810

911
export const remoteDownloadValidatorOptional = vine.compile(
1012
vine.object({
11-
url: vine.string().url().trim().optional(),
13+
url: vine.string().url({
14+
require_tld: false, // Allow local URLs
15+
}).trim().optional(),
1216
})
1317
)
1418

admin/app/validators/zim.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import vine from '@vinejs/vine'
2+
3+
export const listRemoteZimValidator = vine.compile(
4+
vine.object({
5+
start: vine.number().min(0).optional(),
6+
count: vine.number().min(1).max(100).optional(),
7+
query: vine.string().optional(),
8+
})
9+
)

admin/database/seeders/service_seeder.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,6 @@ export default class ServiceSeeder extends BaseSeeder {
2424
is_dependency_service: false,
2525
depends_on: null,
2626
},
27-
{
28-
service_name: DockerService.OPENSTREETMAP_SERVICE_NAME,
29-
friendly_name: 'OpenStreetMap Tile Server',
30-
description: 'Self-hosted OpenStreetMap tile server',
31-
container_image: 'overv/openstreetmap-tile-server',
32-
container_command: 'run',
33-
container_config: JSON.stringify({
34-
HostConfig: {
35-
RestartPolicy: { Name: 'unless-stopped' },
36-
Binds: [
37-
`${DockerService.NOMAD_STORAGE_ABS_PATH}/osm/db:/data/database:rw`,
38-
`${DockerService.NOMAD_STORAGE_ABS_PATH}/osm/tiles:/data/tiles:rw`
39-
],
40-
PortBindings: { '80/tcp': [{ HostPort: '9000' }] }
41-
}
42-
}),
43-
ui_location: '9000',
44-
installed: false,
45-
is_dependency_service: false,
46-
depends_on: null,
47-
},
4827
{
4928
service_name: DockerService.OLLAMA_SERVICE_NAME,
5029
friendly_name: 'Ollama',
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useState } from 'react'
2+
import StyledModal, { StyledModalProps } from './StyledModal'
3+
import Input from './inputs/Input'
4+
import api from '~/lib/api'
5+
6+
export type DownloadURLModalProps = Omit<
7+
StyledModalProps,
8+
'onConfirm' | 'open' | 'confirmText' | 'cancelText' | 'confirmVariant' | 'children'
9+
> & {
10+
suggestedURL?: string
11+
onPreflightSuccess?: (url: string) => void
12+
}
13+
14+
const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
15+
suggestedURL,
16+
onPreflightSuccess,
17+
...modalProps
18+
}) => {
19+
const [url, setUrl] = useState<string>(suggestedURL || '')
20+
const [messages, setMessages] = useState<string[]>([])
21+
const [passedPreflight, setPassedPreflight] = useState<boolean>(false)
22+
23+
async function runPreflightCheck(downloadUrl: string) {
24+
try {
25+
setMessages([`Running preflight check for URL: ${downloadUrl}`])
26+
const res = await api.downloadRemoteMapRegionPreflight(downloadUrl)
27+
if ('message' in res) {
28+
throw new Error(res.message)
29+
}
30+
31+
setMessages((prev) => [
32+
...prev,
33+
`Preflight check passed. Filename: ${res.filename}, Size: ${(res.size / (1024 * 1024)).toFixed(2)} MB`,
34+
])
35+
setPassedPreflight(true)
36+
} catch (error) {
37+
console.error('Preflight check failed:', error)
38+
setMessages((prev) => [...prev, `Preflight check failed: ${error.message}`])
39+
setPassedPreflight(false)
40+
}
41+
}
42+
43+
return (
44+
<StyledModal
45+
{...modalProps}
46+
onConfirm={() => runPreflightCheck(url)}
47+
open={true}
48+
confirmText="Download"
49+
confirmIcon="ArrowDownTrayIcon"
50+
cancelText="Cancel"
51+
confirmVariant="primary"
52+
large
53+
>
54+
<div className="flex flex-col pb-4">
55+
<p className="text-gray-700 mb-8">
56+
Enter the URL of the map region file you wish to download. The URL must be publicly
57+
reachable and end with .pmtiles. A preflight check will be run to verify the file's
58+
availability, type, and approximate size.
59+
</p>
60+
<Input
61+
name="download-url"
62+
label=""
63+
placeholder={suggestedURL || 'Enter download URL...'}
64+
className="mb-4"
65+
value={url}
66+
onChange={(e) => setUrl(e.target.value)}
67+
/>
68+
<div className="min-h-24 max-h-96 overflow-y-auto bg-gray-50 p-4 rounded border border-gray-300 text-left">
69+
{messages.map((message, idx) => (
70+
<p
71+
key={idx}
72+
className="text-sm text-gray-900 font-mono leading-relaxed break-words mb-3"
73+
>
74+
{message}
75+
</p>
76+
))}
77+
</div>
78+
</div>
79+
</StyledModal>
80+
)
81+
}
82+
83+
export default DownloadURLModal

admin/inertia/components/StyledModal.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import StyledButton, { StyledButtonProps } from './StyledButton'
33
import React from 'react'
44
import classNames from '~/lib/classNames'
55

6-
interface StyledModalProps {
6+
export type StyledModalProps = {
77
onClose?: () => void
88
title: string
99
cancelText?: string
10+
cancelIcon?: StyledButtonProps['icon']
1011
confirmText?: string
12+
confirmIcon?: StyledButtonProps['icon']
1113
confirmVariant?: StyledButtonProps['variant']
1214
open: boolean
1315
onCancel?: () => void
@@ -23,7 +25,9 @@ const StyledModal: React.FC<StyledModalProps> = ({
2325
open,
2426
onClose,
2527
cancelText = 'Cancel',
28+
cancelIcon,
2629
confirmText = 'Confirm',
30+
confirmIcon,
2731
confirmVariant = 'action',
2832
onCancel,
2933
onConfirm,
@@ -68,10 +72,11 @@ const StyledModal: React.FC<StyledModalProps> = ({
6872
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
6973
{cancelText && onCancel && (
7074
<StyledButton
71-
variant="secondary"
75+
variant="outline"
7276
onClick={() => {
7377
if (onCancel) onCancel()
7478
}}
79+
icon={cancelIcon}
7580
>
7681
{cancelText}
7782
</StyledButton>
@@ -82,6 +87,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
8287
onClick={() => {
8388
if (onConfirm) onConfirm()
8489
}}
90+
icon={confirmIcon}
8591
>
8692
{confirmText}
8793
</StyledButton>

admin/inertia/components/StyledTable.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ function StyledTable<T extends { [key: string]: any }>({
4848
return (
4949
<div
5050
className={classNames(
51-
'w-full overflow-x-auto bg-white ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-3 shadow-md',
51+
'w-full overflow-x-auto bg-white ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-1 shadow-md',
5252
className
5353
)}
5454
ref={ref}
@@ -107,7 +107,7 @@ function StyledTable<T extends { [key: string]: any }>({
107107
))}
108108
{!loading && data.length === 0 && (
109109
<tr>
110-
<td colSpan={columns.length} className="!text-center ">
110+
<td colSpan={columns.length} className="!text-center py-8 text-gray-500">
111111
{noDataText}
112112
</td>
113113
</tr>

0 commit comments

Comments
 (0)