Skip to content
Merged
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
b9482b5
init after i broke the rebase
greatgitsby Mar 29, 2025
e6dbb00
more Solid-like
greatgitsby Mar 30, 2025
ab357ff
much cleaner component
greatgitsby Mar 30, 2025
9465e24
back here make it less lines
greatgitsby Mar 30, 2025
cd026a2
this was test
greatgitsby Mar 30, 2025
5f52864
one more
greatgitsby Mar 30, 2025
d014930
delete the suspense
greatgitsby Mar 30, 2025
4e79db1
use mutation for cancel action
greatgitsby Mar 30, 2025
91dd07f
drop query data if it fails
greatgitsby Mar 30, 2025
c79b205
fix
greatgitsby Mar 30, 2025
9c53918
no need for clsx
greatgitsby Mar 30, 2025
3728747
let offline queue poll until its empty
greatgitsby Mar 30, 2025
5b07462
cleanup
greatgitsby Mar 30, 2025
485427a
order import
greatgitsby Mar 30, 2025
24df2ad
cleanup
greatgitsby Mar 30, 2025
bb8e71c
rename
greatgitsby Mar 30, 2025
109a65f
split this change into a separate PR
greatgitsby Mar 30, 2025
fc0e781
inline this
greatgitsby Mar 30, 2025
12b3f6f
back to before
greatgitsby Mar 30, 2025
ef69097
this should be span
greatgitsby Mar 30, 2025
7641d11
back
greatgitsby Mar 30, 2025
9a134c7
remove memoization
greatgitsby Mar 30, 2025
ebdb60e
move QueryClientProvider wrapper to AppLayout
greatgitsby Mar 30, 2025
aca2ef2
try fixing tests
greatgitsby Mar 30, 2025
9502a2f
Revert "try fixing tests"
greatgitsby Mar 30, 2025
c4a0548
only establish the QueryClientProvider / devtools around App
greatgitsby Mar 30, 2025
15ae348
feat: setup query client factory
greatgitsby Mar 30, 2025
d22e149
refactor: move queries into api
greatgitsby Mar 31, 2025
6912cbc
reorganize import
greatgitsby Mar 31, 2025
1397a4f
move query client to api
greatgitsby Mar 31, 2025
04b6c78
didnt save in editor
greatgitsby Mar 31, 2025
30bdf6e
refactor into query factory pattern
greatgitsby Mar 31, 2025
6d239bd
not needed anymore
greatgitsby Mar 31, 2025
0fd8999
just combine fields on decorated into same type
greatgitsby Mar 31, 2025
2fe849d
extract to queue query file
greatgitsby Mar 31, 2025
2d06de8
uploading messages bubble up first
greatgitsby Mar 31, 2025
3bf7ac4
move
greatgitsby Mar 31, 2025
325743d
lines
greatgitsby Mar 31, 2025
f26639a
same sort as before
greatgitsby Mar 31, 2025
7c39cd6
test
greatgitsby Mar 31, 2025
9d36fca
we dont even need to decorate on the obj
greatgitsby Mar 31, 2025
a0c64c4
type
greatgitsby Mar 31, 2025
a6863d8
move map to queue
greatgitsby Mar 31, 2025
1bf612b
move declarations back to api
greatgitsby Mar 31, 2025
20dbd44
formatting
greatgitsby Mar 31, 2025
f94b750
revert api changes, move queries to component, query client to api
greatgitsby Mar 31, 2025
51a7a38
add back population of fields to stored objects
greatgitsby Mar 31, 2025
bf25d5a
dont destructure
greatgitsby Mar 31, 2025
9676887
lower bundle size
greatgitsby Mar 31, 2025
2490d3d
Merge remote-tracking branch 'origin/master' into feat/tanstack-query
incognitojam Mar 31, 2025
a1d3578
fix
incognitojam Mar 31, 2025
b9aef8a
Revert "fix"
incognitojam Mar 31, 2025
5712463
fix2
incognitojam Mar 31, 2025
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 @@ -43,6 +43,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 @@ -79,6 +80,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
15 changes: 12 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createSignal, lazy, onCleanup, Show, Suspense, type ParentComponent, type VoidComponent } from 'solid-js'
import { Router, Route } from '@solidjs/router'
import { QueryClientProvider } from '@tanstack/solid-query'
import { SolidQueryDevtools } from '@tanstack/solid-query-devtools'
import { getAppQueryClient } from '~/api/query-client'

import 'leaflet/dist/leaflet.css'

const Login = lazy(() => import('./pages/auth/login'))
Expand Down Expand Up @@ -38,10 +42,15 @@ export const AppLayout: ParentComponent = (props) => {
)
}

const queryClient = getAppQueryClient()

const App: VoidComponent = () => (
<Router root={AppLayout}>
<Routes />
</Router>
<QueryClientProvider client={queryClient}>
<SolidQueryDevtools />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this run in production? I also see we include the solid devtools plugin

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no. see first point in PR description

just checked again. i don't see the tanstack dev tools in prod
image

<Router root={AppLayout}>
<Routes />
</Router>
</QueryClientProvider>
)

export default App
4 changes: 2 additions & 2 deletions src/api/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ export const getDevice = async (dongleId: string) => {
}
}

export const getAthenaOfflineQueue = async (dongleId: string) =>
fetcher<AthenaOfflineQueueResponse>(`/v1/devices/${dongleId}/athena_offline_queue`).catch(() => undefined)
export const getAthenaOfflineQueue = (dongleId: string) =>
fetcher<AthenaOfflineQueueResponse>(`/v1/devices/${dongleId}/athena_offline_queue`)

export const getDeviceLocation = async (dongleId: string) =>
fetcher<DeviceLocation>(`/v1/devices/${dongleId}/location`).catch(() => undefined)
Expand Down
13 changes: 13 additions & 0 deletions src/api/query-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { QueryClient } from '@tanstack/solid-query'
import { queries as uploadQueue } from '~/components/UploadQueue'

const pollingConfig = { retry: false, refetchInterval: 1000 }

export const getAppQueryClient = () => {
const queryClient = new QueryClient()

queryClient.setQueryDefaults(uploadQueue.online(), pollingConfig)
queryClient.setQueryDefaults(uploadQueue.offline(), pollingConfig)

return queryClient
}
2 changes: 1 addition & 1 deletion src/ci/check_bundle_size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const totalCompressedSizeKB = (totalCompressedSize / 1024).toFixed(2)
files.push({}, { path: 'Total', sizeKB: totalSizeKB, compressedSizeKB: totalCompressedSizeKB })
console.table(files, ['path', 'sizeKB', 'compressedSizeKB'])

const upperBoundKB = 255
const upperBoundKB = 270
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
182 changes: 83 additions & 99 deletions src/components/UploadQueue.tsx
Original file line number Diff line number Diff line change
@@ -1,136 +1,119 @@
import { createSignal, For, Match, onCleanup, 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 { createMutation, createQuery, queryOptions, useQueryClient } from '@tanstack/solid-query'
import { createEffect, For, Match, Show, Switch, VoidComponent } from 'solid-js'
import { createStore, reconcile } from 'solid-js/store'
import clsx from 'clsx'
import { getAthenaOfflineQueue } from '~/api/devices'
import LinearProgress from './material/LinearProgress'
import Icon, { IconName } from './material/Icon'
import IconButton from './material/IconButton'
import StatisticBar from './StatisticBar'
import Button from '~/components/material/Button'
import { AthenaOfflineQueueResponse, UploadFilesToUrlsRequest, UploadQueueItem } from '~/types'
import { cancelUpload, getUploadQueue } from '~/api/athena'
import { getAthenaOfflineQueue } from '~/api/devices'

export const queries = {
prefix: ['upload_queue'],

online: () => [...queries.prefix, 'online'],
onlineForDongle: (dongleId: string) => [...queries.online(), dongleId],
getOnline: (dongleId: string) => queryOptions({ queryKey: queries.onlineForDongle(dongleId), queryFn: () => getUploadQueue(dongleId) }),
offline: () => [...queries.prefix, 'offline'],
offlineForDongle: (dongleId: string) => [...queries.offline(), dongleId],
getOffline: (dongleId: string) =>
queryOptions({ queryKey: queries.offlineForDongle(dongleId), queryFn: () => getAthenaOfflineQueue(dongleId) }),
cancelUpload: (dongleId: string) => {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: (ids: string[]) => cancelUpload(dongleId, ids),
onSettled: () => queryClient.invalidateQueries({ queryKey: queries.onlineForDongle(dongleId) }),
}))
},
}

const mapOfflineQueueItems = (data: AthenaOfflineQueueResponse): UploadQueueItem[] =>
data
.filter((item) => item.method === 'uploadFilesToUrls')
.flatMap((item) =>
(item.params as UploadFilesToUrlsRequest).files_data.map((file) => ({
...file,
path: file.fn,
created_at: 0,
current: false,
id: '',
progress: 0,
retry_count: 0,
})),
)

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

const parseUploadPath = (url: string) => {
const parsed = new URL(url)
const populateAttributes = (item: UploadQueueItem): UploadQueueItemWithAttributes => {
const parsed = new URL(item.url)
const parts = parsed.pathname.split('/')
if (parsed.hostname === 'upload.commadotai.com') {
return { route: parts[2], segment: parseInt(parts[3], 10), filename: parts[4], isFirehose: true }
return { ...item, route: parts[2], segment: parseInt(parts[3], 10), filename: parts[4], isFirehose: true }
}
return { route: parts[3], segment: parseInt(parts[4], 10), filename: parts[5], isFirehose: false }
return { ...item, 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: (ids: string[]) => void; item: UploadQueueItemWithAttributes }> = (props) => {
const item = () => props.item
const cancel = () => props.cancel([item().id])
return (
<div class="flex flex-col">
<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'} />
<Icon class="text-on-surface-variant flex-shrink-0 mr-2" name={item().isFirehose ? 'local_fire_department' : 'person'} />
<div class="flex min-w-0 gap-1">
<span class="text-body-sm font-mono truncate text-on-surface">{[item.route, item.segment, item.filename].join(' ')}</span>
<span class="text-body-sm font-mono truncate text-on-surface">{[item().route, item().segment, item().filename].join(' ')}</span>
</div>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0 justify-end">
<Show
when={!item.id || item.progress !== 0}
fallback={<IconButton size="20" name="close_small" onClick={() => cancel(dongleId, [item.id])} />}
>
<Show when={!item().id || item().progress !== 0} fallback={<IconButton size="20" name="close_small" onClick={cancel} />}>
<span class="text-body-sm font-mono whitespace-nowrap pr-[0.5rem]">
{item.id ? `${Math.round(item.progress * 100)}%` : 'Offline'}
{item().id ? `${Math.round(item().progress * 100)}%` : 'Offline'}
</span>
</Show>
</div>
</div>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-surface-container-highest">
<LinearProgress progress={item.progress} color={Math.round(item.progress * 100) === 100 ? 'tertiary' : 'primary'} />
<LinearProgress progress={item().progress} color={Math.round(item().progress * 100) === 100 ? 'tertiary' : 'primary'} />
</div>
</div>
)
}

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={props.iconClass} />
<span class="text-body-lg">{props.message}</span>
</div>
)

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

let timeout: Timer | undefined

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 onlineQueue = createQuery(() => queries.getOnline(props.dongleId))
const offlineQueue = createQuery(() => queries.getOffline(props.dongleId))
const cancel = queries.cancelUpload(props.dongleId)

timeout = setTimeout(fetch, 0)
const [items, setItems] = createStore<UploadQueueItemWithAttributes[]>([])

onCleanup(() => {
clearTimeout(timeout)
timeout = undefined
createEffect(() => {
const online = onlineQueue.isSuccess ? (onlineQueue.data?.result ?? []) : []
const offline = offlineQueue.isSuccess ? mapOfflineQueueItems(offlineQueue.data ?? []) : []
const sorted = [...online, ...offline].map(populateAttributes).sort((a, b) => b.progress - a.progress)
setItems(reconcile(sorted))
})

const cancelAll = () => {
const ids = items.filter((item) => item.id).map((item) => item.id)
if (ids.length === 0) return
cancel.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">
Expand All @@ -143,17 +126,18 @@ const UploadQueue: VoidComponent<{ dongleId: string }> = (props) => {
<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={items}>{(item) => <UploadQueueRow cancel={cancel.mutate} 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 && items.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 && items.length === 0}>
<StatusMessage icon="check" message="Nothing to upload" />
</Match>
</Switch>
</div>
Expand Down
Loading