feat: display model download progress

This commit is contained in:
Jake Turner 2026-02-06 16:20:16 -08:00 committed by Jake Turner
parent 2e0ab10075
commit 12286b9d34
12 changed files with 143 additions and 20 deletions

View File

@ -25,6 +25,7 @@ import type {
import { randomUUID, createHmac } from 'node:crypto' import { randomUUID, createHmac } from 'node:crypto'
import { DockerService } from './docker_service.js' import { DockerService } from './docker_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js' import { SERVICE_NAMES } from '../../constants/service_names.js'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
// HMAC secret for signing submissions to the benchmark repository // HMAC secret for signing submissions to the benchmark repository
// This provides basic protection against casual API abuse. // This provides basic protection against casual API abuse.
@ -45,7 +46,6 @@ const SCORE_WEIGHTS = {
// Benchmark configuration constants // Benchmark configuration constants
const SYSBENCH_IMAGE = 'severalnines/sysbench:latest' const SYSBENCH_IMAGE = 'severalnines/sysbench:latest'
const SYSBENCH_CONTAINER_NAME = 'nomad_benchmark_sysbench' const SYSBENCH_CONTAINER_NAME = 'nomad_benchmark_sysbench'
const BENCHMARK_CHANNEL = 'benchmark-progress'
// Reference model for AI benchmark - small but meaningful // Reference model for AI benchmark - small but meaningful
const AI_BENCHMARK_MODEL = 'llama3.2:1b' const AI_BENCHMARK_MODEL = 'llama3.2:1b'
@ -734,7 +734,7 @@ export class BenchmarkService {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
} }
transmit.broadcast(BENCHMARK_CHANNEL, { transmit.broadcast(BROADCAST_CHANNELS.BENCHMARK_PROGRESS, {
benchmark_id: this.currentBenchmarkId, benchmark_id: this.currentBenchmarkId,
...progress, ...progress,
}) })

View File

@ -11,6 +11,7 @@ import { exec } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
// import { readdir } from 'fs/promises' // import { readdir } from 'fs/promises'
import KVStore from '#models/kv_store' import KVStore from '#models/kv_store'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
@inject() @inject()
export class DockerService { export class DockerService {
@ -788,7 +789,7 @@ export class DockerService {
// } // }
private _broadcast(service: string, status: string, message: string) { private _broadcast(service: string, status: string, message: string) {
transmit.broadcast('service-installation', { transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, {
service_name: service, service_name: service,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status, status,

View File

@ -1,6 +1,7 @@
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
import { QueueService } from './queue_service.js' import { QueueService } from './queue_service.js'
import { RunDownloadJob } from '#jobs/run_download_job' import { RunDownloadJob } from '#jobs/run_download_job'
import { DownloadModelJob } from '#jobs/download_model_job'
import { DownloadJobWithProgress } from '../../types/downloads.js' import { DownloadJobWithProgress } from '../../types/downloads.js'
import { normalize } from 'path' import { normalize } from 'path'
@ -9,17 +10,33 @@ export class DownloadService {
constructor(private queueService: QueueService) {} constructor(private queueService: QueueService) {}
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> { async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
// Get regular file download jobs (zim, map, etc.)
const queue = this.queueService.getQueue(RunDownloadJob.queue) const queue = this.queueService.getQueue(RunDownloadJob.queue)
const jobs = await queue.getJobs(['waiting', 'active', 'delayed']) const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed'])
return jobs const fileDownloads = fileJobs.map((job) => ({
.map((job) => ({ jobId: job.id!.toString(),
jobId: job.id!.toString(), url: job.data.url,
url: job.data.url, progress: parseInt(job.progress.toString(), 10),
progress: parseInt(job.progress.toString(), 10), filepath: normalize(job.data.filepath),
filepath: normalize(job.data.filepath), filetype: job.data.filetype,
filetype: job.data.filetype, }))
}))
.filter((job) => !filetype || job.filetype === filetype) // Get Ollama model download jobs
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed'])
const modelDownloads = modelJobs.map((job) => ({
jobId: job.id!.toString(),
url: job.data.modelName || 'Unknown Model', // Use model name as url
progress: parseInt(job.progress.toString(), 10),
filepath: job.data.modelName || 'Unknown Model', // Use model name as filepath
filetype: 'model',
}))
const allDownloads = [...fileDownloads, ...modelDownloads]
// Filter by filetype if specified
return allDownloads.filter((job) => !filetype || job.filetype === filetype)
} }
} }

View File

@ -10,6 +10,7 @@ import { DownloadModelJob } from '#jobs/download_model_job'
import { SERVICE_NAMES } from '../../constants/service_names.js' import { SERVICE_NAMES } from '../../constants/service_names.js'
import transmit from '@adonisjs/transmit/services/main' import transmit from '@adonisjs/transmit/services/main'
import Fuse, { IFuseOptions } from 'fuse.js' import Fuse, { IFuseOptions } from 'fuse.js'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
const NOMAD_MODELS_API_BASE_URL = 'https://api.projectnomad.us/api/v1/ollama/models' const NOMAD_MODELS_API_BASE_URL = 'https://api.projectnomad.us/api/v1/ollama/models'
const MODELS_CACHE_FILE = path.join(process.cwd(), 'storage', 'ollama-models-cache.json') const MODELS_CACHE_FILE = path.join(process.cwd(), 'storage', 'ollama-models-cache.json')
@ -330,7 +331,7 @@ export class OllamaService {
} }
private broadcastDownloadProgress(model: string, percent: number) { private broadcastDownloadProgress(model: string, percent: number) {
transmit.broadcast('model-download', { transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
model, model,
percent, percent,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),

View File

@ -1,6 +1,14 @@
import env from '#start/env'
import { defineConfig } from '@adonisjs/transmit' import { defineConfig } from '@adonisjs/transmit'
import { redis } from '@adonisjs/transmit/transports'
export default defineConfig({ export default defineConfig({
pingInterval: false, pingInterval: false,
transport: null transport: {
driver: redis({
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT'),
keyPrefix: 'transmit:',
})
}
}) })

View File

@ -0,0 +1,6 @@
export const BROADCAST_CHANNELS = {
BENCHMARK_PROGRESS: 'benchmark-progress',
OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download',
SERVICE_INSTALLATION: 'service-installation',
}

View File

@ -0,0 +1,43 @@
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
import HorizontalBarChart from './HorizontalBarChart'
import StyledSectionHeader from './StyledSectionHeader'
interface ActiveModelDownloadsProps {
withHeader?: boolean
}
const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps) => {
const { downloads } = useOllamaModelDownloads()
return (
<>
{withHeader && <StyledSectionHeader title="Active Model Downloads" className="mt-12 mb-4" />}
<div className="space-y-4">
{downloads && downloads.length > 0 ? (
downloads.map((download) => (
<div
key={download.model}
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow"
>
<HorizontalBarChart
items={[
{
label: download.model,
value: download.percent,
total: '100%',
used: `${download.percent.toFixed(1)}%`,
type: 'ollama-model',
},
]}
/>
</div>
))
) : (
<p className="text-gray-500">No active model downloads</p>
)}
</div>
</>
)
}
export default ActiveModelDownloads

View File

@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { useTransmit } from 'react-adonis-transmit'
export type OllamaModelDownload = {
model: string
percent: number
timestamp: string
}
export default function useOllamaModelDownloads() {
const { subscribe } = useTransmit()
const [downloads, setDownloads] = useState<Map<string, OllamaModelDownload>>(new Map())
useEffect(() => {
const unsubscribe = subscribe('ollama-model-download', (data: OllamaModelDownload) => {
setDownloads((prev) => {
const updated = new Map(prev)
if (data.percent >= 100) {
// If download is complete, keep it for a short time before removing to allow UI to show 100% progress
updated.set(data.model, data)
setTimeout(() => {
setDownloads((current) => {
const next = new Map(current)
next.delete(data.model)
return next
})
}, 2000)
} else {
updated.set(data.model, data)
}
return updated
})
})
return () => {
unsubscribe()
}
}, [subscribe])
const downloadsArray = Array.from(downloads.values())
return { downloads: downloadsArray, activeCount: downloads.size }
}

View File

@ -1,13 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTransmit } from 'react-adonis-transmit' import { useTransmit } from 'react-adonis-transmit'
import { InstallActivityFeedProps } from '~/components/InstallActivityFeed' import { InstallActivityFeedProps } from '~/components/InstallActivityFeed'
import { BROADCAST_CHANNELS } from '../../constants/broadcast'
export default function useServiceInstallationActivity() { export default function useServiceInstallationActivity() {
const { subscribe } = useTransmit() const { subscribe } = useTransmit()
const [installActivity, setInstallActivity] = useState<InstallActivityFeedProps['activity']>([]) const [installActivity, setInstallActivity] = useState<InstallActivityFeedProps['activity']>([])
useEffect(() => { useEffect(() => {
const unsubscribe = subscribe('service-installation', (data: any) => { const unsubscribe = subscribe(BROADCAST_CHANNELS.SERVICE_INSTALLATION, (data: any) => {
setInstallActivity((prev) => [ setInstallActivity((prev) => [
...prev, ...prev,
{ {

View File

@ -23,6 +23,7 @@ import BenchmarkResult from '#models/benchmark_result'
import api from '~/lib/api' import api from '~/lib/api'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import { SERVICE_NAMES } from '../../../constants/service_names' import { SERVICE_NAMES } from '../../../constants/service_names'
import { BROADCAST_CHANNELS } from '../../../constants/broadcast'
type BenchmarkProgressWithID = BenchmarkProgress & { benchmark_id: string } type BenchmarkProgressWithID = BenchmarkProgress & { benchmark_id: string }
@ -295,7 +296,7 @@ export default function BenchmarkPage(props: {
// Listen for benchmark progress via SSE (backup for async mode) // Listen for benchmark progress via SSE (backup for async mode)
useEffect(() => { useEffect(() => {
const unsubscribe = subscribe('benchmark-progress', (data: BenchmarkProgressWithID) => { const unsubscribe = subscribe(BROADCAST_CHANNELS.BENCHMARK_PROGRESS, (data: BenchmarkProgressWithID) => {
setProgress(data) setProgress(data)
if (data.status === 'completed' || data.status === 'error') { if (data.status === 'completed' || data.status === 'error') {
setIsRunning(false) setIsRunning(false)

View File

@ -18,6 +18,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'
import Input from '~/components/inputs/Input' import Input from '~/components/inputs/Input'
import { IconSearch } from '@tabler/icons-react' import { IconSearch } from '@tabler/icons-react'
import useDebounce from '~/hooks/useDebounce' import useDebounce from '~/hooks/useDebounce'
import ActiveModelDownloads from '~/components/ActiveModelDownloads'
export default function ModelsPage(props: { export default function ModelsPage(props: {
models: { models: {
@ -166,6 +167,8 @@ export default function ModelsPage(props: {
/> />
</div> </div>
</div> </div>
<ActiveModelDownloads withHeader />
<StyledSectionHeader title="Models" className="mt-12 mb-4" /> <StyledSectionHeader title="Models" className="mt-12 mb-4" />
<div className="flex justify-start mt-4"> <div className="flex justify-start mt-4">
<Input <Input

View File

@ -1,3 +0,0 @@
export const BROADCAST_CHANNELS = {
DOWNLOADS: 'downloads',
}