Added queing system to the UI
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user