gflow 0.4.18

A lightweight, single-node job scheduler written in Rust.
import { useMemo, useState } from "react"
import {
  type ColumnDef,
  type ColumnFiltersState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getSortedRowModel,
  type SortingState,
  useReactTable,
} from "@tanstack/react-table"
import { ArrowDownAZ, FileText, ListFilter, X } from "lucide-react"

import type { Job } from "@/api"
import { formatTime, toDate } from "@/lib/format"
import {
  exactFilter,
  type GpuFilter,
  gpuSortValue,
  gpuStateFilter,
  jobContext,
  jobGlobalFilter,
  jobName,
  type JobTableColumnId,
  type SortDirection,
  stringColumnFilter,
  uniqueSorted,
} from "@/lib/jobs"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { GpuPill } from "@/components/dashboard/GpuPill"
import { SelectControl } from "@/components/dashboard/SelectControl"
import { EmptyRow } from "@/components/dashboard/StatePanels"
import { StatusBadge } from "@/components/dashboard/StatusBadge"
import { SummaryPill } from "@/components/dashboard/SummaryPill"

export function JobsView({ jobs, onViewLogs }: { jobs: Job[]; onViewLogs: (job: Job) => void }) {
  const [query, setQuery] = useState("")
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
  const [sorting, setSorting] = useState<SortingState>([{ id: "id", desc: true }])

  const states = useMemo(() => uniqueSorted(jobs.map((job) => job.state)), [jobs])
  const users = useMemo(
    () => uniqueSorted(jobs.map((job) => job.submitted_by ?? "unknown")),
    [jobs],
  )

  const columns = useMemo<ColumnDef<Job>[]>(
    () => [
      {
        accessorKey: "id",
        header: "ID",
        cell: ({ row }) => (
          <span className="font-mono text-xs">{row.original.id}</span>
        ),
        sortingFn: "basic",
      },
      {
        accessorKey: "state",
        header: "Status",
        cell: ({ row }) => <StatusBadge value={row.original.state} />,
        filterFn: exactFilter,
      },
      {
        id: "name",
        accessorFn: jobName,
        header: "Name",
        cell: ({ row }) => (
          <div className="max-w-[360px]">
            <div className="truncate font-medium">{jobName(row.original)}</div>
            <div className="truncate text-xs text-muted-foreground">
              {jobContext(row.original)}
            </div>
          </div>
        ),
      },
      {
        id: "user",
        accessorFn: (job) => job.submitted_by ?? "unknown",
        header: "User",
        filterFn: exactFilter,
      },
      {
        id: "gpu",
        accessorFn: (job) => gpuSortValue(job),
        header: "GPU",
        cell: ({ row }) => <GpuPill job={row.original} />,
        filterFn: gpuStateFilter,
        sortingFn: "basic",
      },
      {
        id: "submitted",
        accessorFn: (job) => toDate(job.submitted_at)?.valueOf() ?? 0,
        header: "Submitted",
        cell: ({ row }) => formatTime(row.original.submitted_at),
        sortingFn: "basic",
      },
      {
        id: "scheduled",
        accessorFn: (job) => (job.scheduled_at ? (toDate(job.scheduled_at)?.valueOf() ?? 0) : 0),
        header: "Starts at",
        cell: ({ row }) =>
          row.original.scheduled_at ? formatTime(row.original.scheduled_at) : "—",
        sortingFn: "basic",
      },
      {
        id: "actions",
        header: "Logs",
        enableSorting: false,
        cell: ({ row }) => (
          <Button
            variant="ghost"
            size="icon-xs"
            aria-label={`View logs for job ${row.original.id}`}
            onClick={() => onViewLogs(row.original)}
          >
            <FileText />
          </Button>
        ),
      },
    ],
    [onViewLogs],
  )

  // eslint-disable-next-line react-hooks/incompatible-library
  const table = useReactTable({
    data: jobs,
    columns,
    state: {
      columnFilters,
      globalFilter: query,
      sorting,
    },
    onColumnFiltersChange: setColumnFilters,
    onGlobalFilterChange: setQuery,
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getSortedRowModel: getSortedRowModel(),
    globalFilterFn: jobGlobalFilter,
  })

  const visibleRows = table.getRowModel().rows
  const stateFilter = stringColumnFilter(table, "state")
  const userFilter = stringColumnFilter(table, "user")
  const gpuFilter = stringColumnFilter(table, "gpu") as GpuFilter
  const activeSort = sorting[0]
  const sortField = (activeSort?.id ?? "id") as JobTableColumnId
  const sortDirection: SortDirection = activeSort?.desc === false ? "asc" : "desc"
  const runningJobs = jobs.filter((job) => job.state === "Running").length
  const queuedJobs = jobs.filter((job) => job.state === "Queued").length
  const gpuJobs = jobs.filter((job) => (job.gpus ?? 0) > 0).length
  const hasControlsActive =
    query.trim().length > 0 ||
    stateFilter !== "all" ||
    userFilter !== "all" ||
    gpuFilter !== "all" ||
    sortField !== "id" ||
    sortDirection !== "desc"

  const setColumnFilter = (columnId: JobTableColumnId, value: string) => {
    table.getColumn(columnId)?.setFilterValue(value === "all" ? undefined : value)
  }

  const setSortField = (columnId: JobTableColumnId) => {
    setSorting([{ id: columnId, desc: sortDirection === "desc" }])
  }

  const setSortDirection = (direction: SortDirection) => {
    setSorting([{ id: sortField, desc: direction === "desc" }])
  }

  const resetControls = () => {
    setQuery("")
    setColumnFilters([])
    setSorting([{ id: "id", desc: true }])
  }

  return (
    <Card className="rounded-lg">
      <CardHeader className="gap-4 border-b">
        <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
          <div>
            <CardTitle>Jobs</CardTitle>
            <CardDescription>
              {visibleRows.length} of {jobs.length} visible from the latest page
            </CardDescription>
          </div>
          <div className="flex flex-wrap gap-2">
            <SummaryPill label="Running" value={runningJobs} tone="emerald" />
            <SummaryPill label="Queued" value={queuedJobs} tone="sky" />
            <SummaryPill label="GPU jobs" value={gpuJobs} tone="amber" />
          </div>
        </div>
        <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-[minmax(220px,1.2fr)_140px_160px_150px_150px_120px_auto]">
          <div className="relative">
            <ListFilter className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
            <Input
              value={query}
              onChange={(event) => setQuery(event.target.value)}
              placeholder="Filter jobs"
              className="pl-8"
            />
          </div>
          <SelectControl
            ariaLabel="Filter by status"
            value={stateFilter}
            onChange={(value) => setColumnFilter("state", value)}
          >
            <option value="all">All states</option>
            {states.map((state) => (
              <option key={state} value={state}>
                {state}
              </option>
            ))}
          </SelectControl>
          <SelectControl
            ariaLabel="Filter by user"
            value={userFilter}
            onChange={(value) => setColumnFilter("user", value)}
          >
            <option value="all">All users</option>
            {users.map((user) => (
              <option key={user} value={user}>
                {user}
              </option>
            ))}
          </SelectControl>
          <SelectControl
            ariaLabel="Filter by GPU state"
            value={gpuFilter}
            onChange={(value) => setColumnFilter("gpu", value)}
          >
            <option value="all">All GPU states</option>
            <option value="requested">GPU requested</option>
            <option value="none">No GPU</option>
            <option value="assigned">GPU assigned</option>
            <option value="pending">GPU pending</option>
          </SelectControl>
          <div className="relative">
            <ArrowDownAZ className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
            <SelectControl
              ariaLabel="Sort jobs"
              value={sortField}
              onChange={(value) => setSortField(value as JobTableColumnId)}
              className="pl-8"
            >
              <option value="id">Sort by ID</option>
              <option value="submitted">Sort by submitted</option>
              <option value="state">Sort by status</option>
              <option value="name">Sort by name</option>
              <option value="user">Sort by user</option>
              <option value="gpu">Sort by GPU</option>
            </SelectControl>
          </div>
          <SelectControl
            ariaLabel="Sort direction"
            value={sortDirection}
            onChange={(value) => setSortDirection(value as SortDirection)}
          >
            <option value="desc">Descending</option>
            <option value="asc">Ascending</option>
          </SelectControl>
          <Button
            type="button"
            variant="outline"
            size="sm"
            onClick={resetControls}
            disabled={!hasControlsActive}
            className="h-8 justify-start lg:justify-center"
          >
            <X className="size-4" />
            Reset
          </Button>
        </div>
      </CardHeader>
      <CardContent>
        <div className="overflow-hidden rounded-lg border">
          <ScrollArea className="h-[520px]">
            <Table>
              <TableHeader>
                {table.getHeaderGroups().map((headerGroup) => (
                  <TableRow key={headerGroup.id}>
                    {headerGroup.headers.map((header) => (
                      <TableHead
                        key={header.id}
                        className={cn(
                          "sticky top-0 z-10 bg-muted/95 backdrop-blur",
                          header.column.id === "id" && "w-20",
                        )}
                      >
                        {header.isPlaceholder
                          ? null
                          : flexRender(
                              header.column.columnDef.header,
                              header.getContext(),
                            )}
                      </TableHead>
                    ))}
                  </TableRow>
                ))}
              </TableHeader>
              <TableBody>
                {visibleRows.length ? (
                  visibleRows.map((row) => (
                    <TableRow key={row.id}>
                      {row.getVisibleCells().map((cell) => (
                        <TableCell key={cell.id}>
                          {flexRender(cell.column.columnDef.cell, cell.getContext())}
                        </TableCell>
                      ))}
                    </TableRow>
                  ))
                ) : (
                  <EmptyRow columns={7} label="No jobs match the current filter" />
                )}
              </TableBody>
            </Table>
          </ScrollArea>
        </div>
      </CardContent>
    </Card>
  )
}