Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@solidjs/testing-library": "^0.8.10",
"@tanstack/solid-query-devtools": "^5.70.0",
"@types/bun": "^1.2.5",
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.16",
Expand Down Expand Up @@ -77,6 +78,7 @@
"@sentry/vite-plugin": "^3.2.2",
"@solid-primitives/state-machine": "^0.1.0",
"@solidjs/router": "^0.15.3",
"@tanstack/solid-query": "^5.70.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"hls.js": "^1.5.20",
Expand Down
2 changes: 1 addition & 1 deletion src/ci/check_bundle_size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const totalCompressedSizeKB = (totalCompressedSize / 1024).toFixed(2)
files.push({}, { path: 'Total', sizeKB: totalSizeKB, compressedSizeKB: totalCompressedSizeKB })
console.table(files, ['path', 'sizeKB', 'compressedSizeKB'])

const upperBoundKB = 265
const upperBoundKB = 280
const lowerBoundKB = upperBoundKB - 10
if (totalCompressedSize < lowerBoundKB * 1024) {
console.warn(`Bundle size lower than expected, let's lower the limit! (${totalCompressedSizeKB}KB < ${lowerBoundKB}KB)`)
Expand Down
162 changes: 77 additions & 85 deletions src/components/UploadQueue.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { createSignal, For, Match, onCleanup, Show, Switch, VoidComponent } from 'solid-js'
import { createMutation, createQuery, useQueryClient } from '@tanstack/solid-query'
import { createEffect, For, Match, Show, Switch, VoidComponent } from 'solid-js'
import { cancelUpload, getUploadQueue } from '~/api/athena'
import { UploadFilesToUrlsRequest, UploadQueueItem } from '~/types'
import LinearProgress from './material/LinearProgress'
import Icon from './material/Icon'
import { createStore, reconcile } from 'solid-js/store'
import clsx from 'clsx'
import Icon, { IconName } from './material/Icon'
import { getAthenaOfflineQueue } from '~/api/devices'
import IconButton from './material/IconButton'
import StatisticBar from './StatisticBar'
import { createStore, reconcile } from 'solid-js/store'
import clsx from 'clsx'

interface DecoratedUploadQueueItem extends UploadQueueItem {
route: string
segment: number
filename: string
isFirehose: boolean
pendingDeletion: boolean
}

const parseUploadPath = (url: string) => {
Expand All @@ -25,16 +27,9 @@ const parseUploadPath = (url: string) => {
return { route: parts[3], segment: parseInt(parts[4], 10), filename: parts[5], isFirehose: false }
}

const cancel = (dongleId: string, ids: string[]) => {
if (ids.length === 0) return
cancelUpload(dongleId, ids).catch((error) => {
console.error('Error canceling uploads', error)
})
}

const UploadQueueRow: VoidComponent<{ dongleId: string; item: DecoratedUploadQueueItem }> = ({ dongleId, item }) => {
const UploadQueueRow: VoidComponent<{ cancel: (id: string) => void; item: DecoratedUploadQueueItem }> = ({ cancel, item }) => {
return (
<div class="flex flex-col">
<div class={clsx('flex flex-col', item.pendingDeletion && 'opacity-50 transition-opacity duration-150')}>
<div class="flex items-center justify-between flex-wrap mb-1 gap-x-4 min-w-0">
<div class="flex items-center min-w-0 flex-1">
<Icon class="text-on-surface-variant flex-shrink-0 mr-2" name={item.isFirehose ? 'local_fire_department' : 'person'} />
Expand All @@ -45,7 +40,7 @@ const UploadQueueRow: VoidComponent<{ dongleId: string; item: DecoratedUploadQue
<div class="flex items-center gap-0.5 flex-shrink-0 justify-end">
<Show
when={!item.id || item.progress !== 0}
fallback={<IconButton class="text-red-300" size="20" name="close_small" onClick={() => cancel(dongleId, [item.id])} />}
fallback={<IconButton class="text-red-300" size="20" name="close_small" onClick={() => cancel(item.id)} />}
>
<span class="text-body-sm font-mono whitespace-nowrap pr-[0.5rem]">
{item.id ? `${Math.round(item.progress * 100)}%` : 'Offline'}
Expand All @@ -60,97 +55,94 @@ const UploadQueueRow: VoidComponent<{ dongleId: string; item: DecoratedUploadQue
)
}

const WAITING = 'Waiting for device to connect...'
const StatusMessage: VoidComponent<{ iconClass?: string; icon: IconName; message: string }> = (props) => (
<div class="flex items-center gap-2">
<Icon name={props.icon} class={clsx(props.iconClass)} />
<div>{props.message}</div>
</div>
)

const UploadQueue: VoidComponent<{ dongleId: string }> = (props) => {
const [error, setError] = createSignal<string | undefined>(WAITING)
const [items, setItems] = createStore<DecoratedUploadQueueItem[]>([])
const dongleId = () => props.dongleId

let timeout: Timer | undefined
const onlineQueue = createQuery(() => ({
queryKey: ['online_queue', dongleId()],
queryFn: () => getUploadQueue(dongleId()),
select: (data) =>
data.result
?.map((item) => ({ ...item, ...parseUploadPath(item.url), pendingDeletion: false }))
.sort((a, b) => b.progress - a.progress) || [],
retry: false,
refetchInterval: 1000,
}))

const cancelAll = () =>
cancel(
props.dongleId,
items.map((item) => item.id),
)
const fetch = () => {
getAthenaOfflineQueue(props.dongleId)
.then((res) => {
if (!error()) return
setItems(
reconcile(
res
?.filter((r) => r.method === 'uploadFilesToUrls')
.flatMap((item) => {
return (item.params as UploadFilesToUrlsRequest).files_data.map((file) => ({
...file,
...parseUploadPath(file.url),
path: file.fn,
created_at: 0,
current: false,
id: '',
progress: 0,
retry_count: 0,
}))
}) || [],
),
)
})
.catch((error) => {
console.error('Error fetching offline queue', error)
})
getUploadQueue(props.dongleId)
.then((res) => {
if (res.error) {
setError(res.error)
return
}
setItems(
reconcile(res.result?.map((item) => ({ ...item, ...parseUploadPath(item.url) })).sort((a, b) => b.progress - a.progress) || []),
)
setError(undefined)
})
.catch((error) => {
if (error instanceof Error && error.cause instanceof Response && error.cause.status === 404) {
setError('Device is offline')
return
}
setError(error.toString())
})
.finally(() => {
if (!timeout) return
timeout = setTimeout(fetch, 1000)
})
}
const offlineQueue = createQuery(() => ({
queryKey: ['offline_queue', dongleId()],
queryFn: () => getAthenaOfflineQueue(dongleId()),
enabled: !onlineQueue.isSuccess,
select: (data) =>
data
?.filter((item) => item.method === 'uploadFilesToUrls')
.flatMap((item) =>
(item.params as UploadFilesToUrlsRequest).files_data.map((file) => ({
...file,
...parseUploadPath(file.url),
path: file.fn,
created_at: 0,
current: false,
id: '',
progress: 0,
retry_count: 0,
pendingDeletion: false,
})),
) || [],
retry: false,
refetchInterval: 1000,
}))

timeout = setTimeout(fetch, 0)
const queryClient = useQueryClient()
const cancelMutation = createMutation(() => ({
mutationFn: (ids: string[]) => cancelUpload(dongleId(), ids),
onMutate: (ids: string[]) => setItemStore((item) => ids.includes(item.id) && !item.current, 'pendingDeletion', true),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['online_queue', dongleId()] }),
}))

onCleanup(() => {
clearTimeout(timeout)
timeout = undefined
const [itemStore, setItemStore] = createStore<DecoratedUploadQueueItem[]>([])
createEffect(() => {
const online = onlineQueue.isSuccess ? (onlineQueue.data ?? []) : []
const offline = offlineQueue.isSuccess ? (offlineQueue.data ?? []) : []
setItemStore(reconcile([...online, ...offline]))
})

const cancelOne = (id: string) => cancelMutation.mutate([id])
const cancelAll = () => {
const ids = itemStore.filter((item) => item.id).map((item) => item.id)
if (ids.length === 0) return
cancelMutation.mutate(ids)
}

return (
<div class="flex flex-col gap-4 bg-surface-container-lowest">
<div class="flex p-4 justify-between items-center border-b-2 border-b-surface-container-low">
<StatisticBar statistics={[{ label: 'Queued', value: () => items.length }]} />
<IconButton name="close" onClick={cancelAll} />
<StatisticBar statistics={[{ label: 'Queued', value: () => itemStore.length }]} />
<IconButton name="close" onClick={() => cancelAll()} />
</div>
<div class="relative h-[calc(4*3rem)] sm:h-[calc(6*3rem)] flex justify-center items-center text-on-surface-variant">
<Switch
fallback={
<div class="absolute inset-0 bottom-4 flex flex-col gap-2 px-4 overflow-y-auto hide-scrollbar">
<For each={items}>{(item) => <UploadQueueRow dongleId={props.dongleId} item={item} />}</For>
<For each={itemStore}>{(item) => <UploadQueueRow cancel={cancelOne} item={item} />}</For>
</div>
}
>
<Match when={error() && items.length === 0}>
<Icon class={clsx(error() === WAITING && 'animate-spin')} name={error() === WAITING ? 'progress_activity' : 'error'} />
<span class="ml-2">{error()}</span>
<Match when={!onlineQueue.isFetched}>
<StatusMessage iconClass="animate-spin" icon="autorenew" message="Waiting for device to connect..." />
</Match>
<Match when={onlineQueue.isFetched && !onlineQueue.isSuccess && itemStore.length === 0}>
<StatusMessage icon="error" message="Device offline" />
</Match>
<Match when={items.length === 0}>
<Icon name="check" />
<span class="ml-2">Nothing to upload</span>
<Match when={onlineQueue.isFetched && onlineQueue.isSuccess && itemStore.length === 0}>
<StatusMessage icon="check" message="Nothing to upload" />
</Match>
</Switch>
</div>
Expand Down
12 changes: 11 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
import './index.css'

import * as Sentry from '@sentry/solid'
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'
import { render } from 'solid-js/web'
import App from './App'
import './pwa.ts'
import { SolidQueryDevtools } from '@tanstack/solid-query-devtools'

const environment = import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined
Sentry.init({
Expand All @@ -17,4 +19,12 @@ const root = document.getElementById('root')

if (!root) throw new Error('No #root element found in the DOM.')

render(() => <App />, root)
render(
() => (
<QueryClientProvider client={new QueryClient()}>
<SolidQueryDevtools />
<App />
</QueryClientProvider>
),
root,
)
Loading