diff --git a/admin/app/services/benchmark_service.ts b/admin/app/services/benchmark_service.ts index 7f8d810..780be36 100644 --- a/admin/app/services/benchmark_service.ts +++ b/admin/app/services/benchmark_service.ts @@ -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, }) diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 28cc928..db5dfe4 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -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, diff --git a/admin/app/services/download_service.ts b/admin/app/services/download_service.ts index 67d05aa..276971f 100644 --- a/admin/app/services/download_service.ts +++ b/admin/app/services/download_service.ts @@ -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 { + // 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) } } diff --git a/admin/app/services/ollama_service.ts b/admin/app/services/ollama_service.ts index 9db5ac2..9c4bc1b 100644 --- a/admin/app/services/ollama_service.ts +++ b/admin/app/services/ollama_service.ts @@ -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(), diff --git a/admin/config/transmit.ts b/admin/config/transmit.ts index fc02925..f8862d7 100644 --- a/admin/config/transmit.ts +++ b/admin/config/transmit.ts @@ -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:', + }) + } }) \ No newline at end of file diff --git a/admin/constants/broadcast.ts b/admin/constants/broadcast.ts new file mode 100644 index 0000000..ce0c106 --- /dev/null +++ b/admin/constants/broadcast.ts @@ -0,0 +1,6 @@ + +export const BROADCAST_CHANNELS = { + BENCHMARK_PROGRESS: 'benchmark-progress', + OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download', + SERVICE_INSTALLATION: 'service-installation', +} \ No newline at end of file diff --git a/admin/inertia/components/ActiveModelDownloads.tsx b/admin/inertia/components/ActiveModelDownloads.tsx new file mode 100644 index 0000000..1727fe5 --- /dev/null +++ b/admin/inertia/components/ActiveModelDownloads.tsx @@ -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 && } +
+ {downloads && downloads.length > 0 ? ( + downloads.map((download) => ( +
+ +
+ )) + ) : ( +

No active model downloads

+ )} +
+ + ) +} + +export default ActiveModelDownloads diff --git a/admin/inertia/hooks/useOllamaModelDownloads.ts b/admin/inertia/hooks/useOllamaModelDownloads.ts new file mode 100644 index 0000000..701b809 --- /dev/null +++ b/admin/inertia/hooks/useOllamaModelDownloads.ts @@ -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>(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 } +} diff --git a/admin/inertia/hooks/useServiceInstallationActivity.ts b/admin/inertia/hooks/useServiceInstallationActivity.ts index 4586580..467adbb 100644 --- a/admin/inertia/hooks/useServiceInstallationActivity.ts +++ b/admin/inertia/hooks/useServiceInstallationActivity.ts @@ -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([]) useEffect(() => { - const unsubscribe = subscribe('service-installation', (data: any) => { + const unsubscribe = subscribe(BROADCAST_CHANNELS.SERVICE_INSTALLATION, (data: any) => { setInstallActivity((prev) => [ ...prev, { diff --git a/admin/inertia/pages/settings/benchmark.tsx b/admin/inertia/pages/settings/benchmark.tsx index eb984c5..30ab4c7 100644 --- a/admin/inertia/pages/settings/benchmark.tsx +++ b/admin/inertia/pages/settings/benchmark.tsx @@ -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) diff --git a/admin/inertia/pages/settings/models.tsx b/admin/inertia/pages/settings/models.tsx index a7fa3f3..997892d 100644 --- a/admin/inertia/pages/settings/models.tsx +++ b/admin/inertia/pages/settings/models.tsx @@ -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: { /> + +