Added queing system to the UI

This commit is contained in:
Jaret Burkett
2025-10-14 12:00:42 -06:00
parent 0c9e1c3deb
commit 7e34a03113
30 changed files with 750 additions and 273 deletions

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { GpuInfo } from '@/types';
import { ChevronRight, Thermometer, Zap, Clock, HardDrive, Fan, Cpu } from 'lucide-react';
import { Thermometer, Zap, Clock, HardDrive, Fan, Cpu } from 'lucide-react';
interface GPUWidgetProps {
gpu: GpuInfo;
@@ -24,7 +24,7 @@ export default function GPUWidget({ gpu }: GPUWidgetProps) {
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<h2 className="font-semibold text-gray-100">{gpu.name}</h2>
<span className="px-2 py-0.5 bg-gray-700 rounded-full text-xs text-gray-300">#{gpu.index}</span>
<span className="px-2 py-0.5 bg-gray-700 rounded-full text-xs text-gray-300"># {gpu.index}</span>
</div>
</div>

View File

@@ -1,9 +1,10 @@
import Link from 'next/link';
import { Eye, Trash2, Pen, Play, Pause, Cog } from 'lucide-react';
import { Eye, Trash2, Pen, Play, Pause, Cog, X } from 'lucide-react';
import { Button } from '@headlessui/react';
import { openConfirm } from '@/components/ConfirmModal';
import { Job } from '@prisma/client';
import { startJob, stopJob, deleteJob, getAvaliableJobActions, markJobAsStopped } from '@/utils/jobs';
import { startQueue } from '@/utils/queue';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
interface JobActionBarProps {
@@ -12,10 +13,18 @@ interface JobActionBarProps {
afterDelete?: () => void;
hideView?: boolean;
className?: string;
autoStartQueue?: boolean;
}
export default function JobActionBar({ job, onRefresh, afterDelete, className, hideView }: JobActionBarProps) {
const { canStart, canStop, canDelete, canEdit } = getAvaliableJobActions(job);
export default function JobActionBar({
job,
onRefresh,
afterDelete,
className,
hideView,
autoStartQueue = false,
}: JobActionBarProps) {
const { canStart, canStop, canDelete, canEdit, canRemoveFromQueue } = getAvaliableJobActions(job);
if (!afterDelete) afterDelete = onRefresh;
@@ -26,6 +35,10 @@ export default function JobActionBar({ job, onRefresh, afterDelete, className, h
onClick={async () => {
if (!canStart) return;
await startJob(job.id);
// start the queue as well
if (autoStartQueue) {
await startQueue(job.gpu_ids);
}
if (onRefresh) onRefresh();
}}
className={`ml-2 opacity-100`}
@@ -33,6 +46,18 @@ export default function JobActionBar({ job, onRefresh, afterDelete, className, h
<Play />
</Button>
)}
{canRemoveFromQueue && (
<Button
onClick={async () => {
if (!canRemoveFromQueue) return;
await markJobAsStopped(job.id);
if (onRefresh) onRefresh();
}}
className={`ml-2 opacity-100`}
>
<X />
</Button>
)}
{canStop && (
<Button
onClick={() => {

View File

@@ -1,16 +1,30 @@
import { useMemo } from 'react';
import useJobsList from '@/hooks/useJobsList';
import Link from 'next/link';
import UniversalTable, { TableColumn } from '@/components/UniversalTable';
import { JobConfig } from '@/types';
import { GpuInfo, JobConfig } from '@/types';
import JobActionBar from './JobActionBar';
import { Job, Queue } from '@prisma/client';
import useQueueList from '@/hooks/useQueueList';
import classNames from 'classnames';
import { startQueue, stopQueue } from '@/utils/queue';
import { CgSpinner } from 'react-icons/cg';
import useGPUInfo from '@/hooks/useGPUInfo';
interface JobsTableProps {
autoStartQueue?: boolean;
onlyActive?: boolean;
}
export default function JobsTable({ onlyActive = false }: JobsTableProps) {
const { jobs, status, refreshJobs } = useJobsList(onlyActive);
const isLoading = status === 'loading';
const { jobs, status, refreshJobs } = useJobsList(onlyActive, 5000);
const { queues, status: queueStatus, refreshQueues } = useQueueList();
const { gpuList, isGPUInfoLoaded } = useGPUInfo();
const refresh = () => {
refreshJobs();
refreshQueues();
};
const columns: TableColumn[] = [
{
@@ -18,6 +32,9 @@ export default function JobsTable({ onlyActive = false }: JobsTableProps) {
key: 'name',
render: row => (
<Link href={`/jobs/${row.id}`} className="font-medium whitespace-nowrap">
{['running', 'stopping'].includes(row.status) ? (
<CgSpinner className="inline animate-spin mr-2 text-blue-400" />
) : null}
{row.name}
</Link>
),
@@ -30,11 +47,11 @@ export default function JobsTable({ onlyActive = false }: JobsTableProps) {
const totalSteps = jobConfig.config.process[0].train.steps;
return (
<div className="flex items-center">
<span>
<div>
<div className="text-xs text-gray-400">
{row.step} / {totalSteps}
</span>
<div className="w-16 bg-gray-700 rounded-full h-1.5 ml-2">
</div>
<div className="bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full"
style={{ width: `${(row.step / totalSteps) * 100}%` }}
@@ -70,10 +87,115 @@ export default function JobsTable({ onlyActive = false }: JobsTableProps) {
key: 'actions',
className: 'text-right',
render: row => {
return <JobActionBar job={row} onRefresh={refreshJobs} />;
return <JobActionBar job={row} onRefresh={refreshJobs} autoStartQueue={false} />;
},
},
];
return <UniversalTable columns={columns} rows={jobs} isLoading={isLoading} onRefresh={refreshJobs} />;
const jobsDict = useMemo(() => {
if (!isGPUInfoLoaded) return {};
if (jobs.length === 0) return {};
let jd: { [key: string]: { name: string; jobs: Job[] } } = {};
gpuList.forEach(gpu => {
jd[`${gpu.index}`] = { name: `${gpu.name}`, jobs: [] };
});
jd['Idle'] = { name: 'Idle', jobs: [] };
jobs.forEach(job => {
const gpu = gpuList.find(gpu => job.gpu_ids?.split(',').includes(gpu.index.toString())) as GpuInfo;
const key = `${gpu.index}`;
if (['queued', 'running', 'stopping'].includes(job.status) && key in jd) {
jd[key].jobs.push(job);
} else {
jd['Idle'].jobs.push(job);
}
});
// sort the queued/running jobs by queue position
Object.keys(jd).forEach(key => {
if (key === 'Idle') return;
jd[key].jobs.sort((a, b) => {
if (a.queue_position === null) return 1;
if (b.queue_position === null) return -1;
return a.queue_position - b.queue_position;
});
});
return jd;
}, [jobs, queues, isGPUInfoLoaded]);
let isLoading = status === 'loading' || queueStatus === 'loading' || !isGPUInfoLoaded;
// if job dict is populated, we are always loaded
if (Object.keys(jobsDict).length > 0) isLoading = false;
return (
<div>
{Object.keys(jobsDict)
.sort()
.filter(key => key !== 'Idle')
.map(gpuKey => {
const queue = queues.find(q => `${q.gpu_ids}` === gpuKey) as Queue;
return (
<div key={gpuKey} className="mb-6">
<div
className={classNames(
'text-md flex px-4 py-1 rounded-t-lg',
{ 'bg-green-900': queue?.is_running },
{ 'bg-red-900': !queue?.is_running },
)}
>
<div className="flex items-center space-x-2 flex-1 py-2">
<h2 className="font-semibold text-gray-100">{jobsDict[gpuKey].name}</h2>
<span className="px-2 py-0.5 bg-gray-700 rounded-full text-xs text-gray-300"># {queue?.gpu_ids}</span>
</div>
<div className="text-sm text-gray-300 italic flex items-center">
{queue?.is_running ? (
<>
<span className="text-green-400 mr-2">Queue Running</span>
<button
onClick={async () => {
await stopQueue(queue.gpu_ids as string);
refresh();
}}
className="ml-4 text-xs bg-red-900 hover:bg-red-800 px-2 py-1 rounded"
>
STOP
</button>
</>
) : (
<>
<span className="text-red-400 mr-2">Queue Stopped</span>
<button
onClick={async () => {
await startQueue(queue.gpu_ids as string);
refresh();
}}
className="ml-4 text-xs bg-green-700 hover:bg-green-600 px-2 py-1 rounded"
>
START
</button>
</>
)}
</div>
</div>
<UniversalTable
columns={columns}
rows={jobsDict[gpuKey].jobs}
isLoading={isLoading}
onRefresh={refresh}
theadClassName={queue?.is_running ? 'bg-green-950' : 'bg-red-950'}
/>
</div>
);
})}
{!onlyActive && Object.keys(jobsDict).includes('Idle') && (
<div className="mb-6 opacity-50">
<div className="text-md flex px-4 py-1 rounded-t-lg bg-slate-600">
<div className="flex items-center space-x-2 flex-1 py-2">
<h2 className="font-semibold text-gray-100">Idle</h2>
</div>
</div>
<UniversalTable columns={columns} rows={jobsDict['Idle'].jobs} isLoading={isLoading} onRefresh={refresh} />
</div>
)}
</div>
);
}

View File

@@ -1,17 +1,18 @@
import Link from 'next/link';
import { Home, Settings, BrainCircuit, Images, Plus} from 'lucide-react';
import { FaXTwitter, FaDiscord, FaYoutube } from "react-icons/fa6";
import { Home, Settings, BrainCircuit, Images, Plus } from 'lucide-react';
import { FaXTwitter, FaDiscord, FaYoutube } from 'react-icons/fa6';
const Sidebar = () => {
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: Home },
{ name: 'New Job', href: '/jobs/new', icon: Plus },
{ name: 'Training Jobs', href: '/jobs', icon: BrainCircuit },
{ name: 'Training Queue', href: '/jobs', icon: BrainCircuit },
{ name: 'Datasets', href: '/datasets', icon: Images },
{ name: 'Settings', href: '/settings', icon: Settings },
];
const socialsBoxClass = 'flex flex-col items-center justify-center p-1 hover:bg-gray-800 rounded-lg transition-colors';
const socialsBoxClass =
'flex flex-col items-center justify-center p-1 hover:bg-gray-800 rounded-lg transition-colors';
const socialIconClass = 'w-5 h-5 text-gray-400 hover:text-white';
return (
@@ -60,30 +61,15 @@ const Sidebar = () => {
{/* Social links grid */}
<div className="px-1 py-1 border-t border-gray-800">
<div className="grid grid-cols-3 gap-4">
<a
href="https://discord.gg/VXmU2f5WEU"
target="_blank"
rel="noreferrer"
className={socialsBoxClass}
>
<a href="https://discord.gg/VXmU2f5WEU" target="_blank" rel="noreferrer" className={socialsBoxClass}>
<FaDiscord className={socialIconClass} />
{/* <span className="text-xs text-gray-500 mt-1">Discord</span> */}
</a>
<a
href="https://www.youtube.com/@ostrisai"
target="_blank"
rel="noreferrer"
className={socialsBoxClass}
>
<a href="https://www.youtube.com/@ostrisai" target="_blank" rel="noreferrer" className={socialsBoxClass}>
<FaYoutube className={socialIconClass} />
{/* <span className="text-xs text-gray-500 mt-1">YouTube</span> */}
</a>
<a
href="https://x.com/ostrisai"
target="_blank"
rel="noreferrer"
className={socialsBoxClass}
>
<a href="https://x.com/ostrisai" target="_blank" rel="noreferrer" className={socialsBoxClass}>
<FaXTwitter className={socialIconClass} />
{/* <span className="text-xs text-gray-500 mt-1">X</span> */}
</a>

View File

@@ -16,10 +16,17 @@ interface TableProps {
columns: TableColumn[];
rows: TableRow[];
isLoading: boolean;
theadClassName?: string;
onRefresh: () => void;
}
export default function UniversalTable({ columns, rows, isLoading, onRefresh = () => {} }: TableProps) {
export default function UniversalTable({
columns,
rows,
isLoading,
theadClassName = 'text-gray-400',
onRefresh = () => {},
}: TableProps) {
return (
<div className="w-full bg-gray-900 rounded-md shadow-md">
{isLoading ? (
@@ -39,7 +46,7 @@ export default function UniversalTable({ columns, rows, isLoading, onRefresh = (
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left text-gray-300">
<thead className="text-xs uppercase bg-gray-800 text-gray-400">
<thead className={classNames('text-xs uppercase bg-gray-800', theadClassName)}>
<tr>
{columns.map(column => (
<th key={column.key} className="px-3 py-2">