mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
feat: display model download progress
This commit is contained in:
parent
2e0ab10075
commit
12286b9d34
|
|
@ -25,6 +25,7 @@ import type {
|
|||
import { randomUUID, createHmac } from 'node:crypto'
|
||||
import { DockerService } from './docker_service.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
|
||||
// This provides basic protection against casual API abuse.
|
||||
|
|
@ -45,7 +46,6 @@ const SCORE_WEIGHTS = {
|
|||
// Benchmark configuration constants
|
||||
const SYSBENCH_IMAGE = 'severalnines/sysbench:latest'
|
||||
const SYSBENCH_CONTAINER_NAME = 'nomad_benchmark_sysbench'
|
||||
const BENCHMARK_CHANNEL = 'benchmark-progress'
|
||||
|
||||
// Reference model for AI benchmark - small but meaningful
|
||||
const AI_BENCHMARK_MODEL = 'llama3.2:1b'
|
||||
|
|
@ -734,7 +734,7 @@ export class BenchmarkService {
|
|||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
transmit.broadcast(BENCHMARK_CHANNEL, {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.BENCHMARK_PROGRESS, {
|
||||
benchmark_id: this.currentBenchmarkId,
|
||||
...progress,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { exec } from 'child_process'
|
|||
import { promisify } from 'util'
|
||||
// import { readdir } from 'fs/promises'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
|
||||
|
||||
@inject()
|
||||
export class DockerService {
|
||||
|
|
@ -788,7 +789,7 @@ export class DockerService {
|
|||
// }
|
||||
|
||||
private _broadcast(service: string, status: string, message: string) {
|
||||
transmit.broadcast('service-installation', {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, {
|
||||
service_name: service,
|
||||
timestamp: new Date().toISOString(),
|
||||
status,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { inject } from '@adonisjs/core'
|
||||
import { QueueService } from './queue_service.js'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import { DownloadModelJob } from '#jobs/download_model_job'
|
||||
import { DownloadJobWithProgress } from '../../types/downloads.js'
|
||||
import { normalize } from 'path'
|
||||
|
||||
|
|
@ -9,17 +10,33 @@ export class DownloadService {
|
|||
constructor(private queueService: QueueService) {}
|
||||
|
||||
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
|
||||
// Get regular file download jobs (zim, map, etc.)
|
||||
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
|
||||
.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
url: job.data.url,
|
||||
progress: parseInt(job.progress.toString(), 10),
|
||||
filepath: normalize(job.data.filepath),
|
||||
filetype: job.data.filetype,
|
||||
}))
|
||||
.filter((job) => !filetype || job.filetype === filetype)
|
||||
const fileDownloads = fileJobs.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
url: job.data.url,
|
||||
progress: parseInt(job.progress.toString(), 10),
|
||||
filepath: normalize(job.data.filepath),
|
||||
filetype: job.data.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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { DownloadModelJob } from '#jobs/download_model_job'
|
|||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import transmit from '@adonisjs/transmit/services/main'
|
||||
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 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) {
|
||||
transmit.broadcast('model-download', {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
|
||||
model,
|
||||
percent,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import env from '#start/env'
|
||||
import { defineConfig } from '@adonisjs/transmit'
|
||||
import { redis } from '@adonisjs/transmit/transports'
|
||||
|
||||
export default defineConfig({
|
||||
pingInterval: false,
|
||||
transport: null
|
||||
transport: {
|
||||
driver: redis({
|
||||
host: env.get('REDIS_HOST'),
|
||||
port: env.get('REDIS_PORT'),
|
||||
keyPrefix: 'transmit:',
|
||||
})
|
||||
}
|
||||
})
|
||||
6
admin/constants/broadcast.ts
Normal file
6
admin/constants/broadcast.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
export const BROADCAST_CHANNELS = {
|
||||
BENCHMARK_PROGRESS: 'benchmark-progress',
|
||||
OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download',
|
||||
SERVICE_INSTALLATION: 'service-installation',
|
||||
}
|
||||
43
admin/inertia/components/ActiveModelDownloads.tsx
Normal file
43
admin/inertia/components/ActiveModelDownloads.tsx
Normal 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
|
||||
45
admin/inertia/hooks/useOllamaModelDownloads.ts
Normal file
45
admin/inertia/hooks/useOllamaModelDownloads.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTransmit } from 'react-adonis-transmit'
|
||||
import { InstallActivityFeedProps } from '~/components/InstallActivityFeed'
|
||||
import { BROADCAST_CHANNELS } from '../../constants/broadcast'
|
||||
|
||||
export default function useServiceInstallationActivity() {
|
||||
const { subscribe } = useTransmit()
|
||||
const [installActivity, setInstallActivity] = useState<InstallActivityFeedProps['activity']>([])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe('service-installation', (data: any) => {
|
||||
const unsubscribe = subscribe(BROADCAST_CHANNELS.SERVICE_INSTALLATION, (data: any) => {
|
||||
setInstallActivity((prev) => [
|
||||
...prev,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import BenchmarkResult from '#models/benchmark_result'
|
|||
import api from '~/lib/api'
|
||||
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
||||
import { SERVICE_NAMES } from '../../../constants/service_names'
|
||||
import { BROADCAST_CHANNELS } from '../../../constants/broadcast'
|
||||
|
||||
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)
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribe('benchmark-progress', (data: BenchmarkProgressWithID) => {
|
||||
const unsubscribe = subscribe(BROADCAST_CHANNELS.BENCHMARK_PROGRESS, (data: BenchmarkProgressWithID) => {
|
||||
setProgress(data)
|
||||
if (data.status === 'completed' || data.status === 'error') {
|
||||
setIsRunning(false)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'
|
|||
import Input from '~/components/inputs/Input'
|
||||
import { IconSearch } from '@tabler/icons-react'
|
||||
import useDebounce from '~/hooks/useDebounce'
|
||||
import ActiveModelDownloads from '~/components/ActiveModelDownloads'
|
||||
|
||||
export default function ModelsPage(props: {
|
||||
models: {
|
||||
|
|
@ -166,6 +167,8 @@ export default function ModelsPage(props: {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ActiveModelDownloads withHeader />
|
||||
|
||||
<StyledSectionHeader title="Models" className="mt-12 mb-4" />
|
||||
<div className="flex justify-start mt-4">
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export const BROADCAST_CHANNELS = {
|
||||
DOWNLOADS: 'downloads',
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user