SPB Git

Repository Public

page.tsx 9.0 KB 165 lines tsx

1'use client';2import { Suspense, useState } from 'react';3import dynamic from 'next/dynamic';4import { useRouter, useSearchParams } from 'next/navigation';5import { useQueryClient } from '@tanstack/react-query';6import type { AgentTask } from '@cr/shared';7import { ArrowLeft, Download, Plus } from 'lucide-react';8import { api, API_BASE, errorMessage } from '@/lib/api';9import { qk, upsertTask, useProjects, useTask, useTasks } from '@/lib/queries';10import { useBreakpoint, useNow } from '@/hooks/use-viewport';11import { DASH, fmtBytes, fmtDateTime, fmtDuration, fmtNum } from '@/lib/format';12import { TASK_ACTIVE } from '@/lib/states';13import { toast } from '@/stores/toasts';14import { Button, IconButton } from '@/components/ui/button';15import { ConfirmDialog } from '@/components/ui/dialog';16import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner } from '@/components/ui/misc';17import { TaskStateBadge } from '@/components/sessions/state-badge';18import { CreateTaskDialog } from '@/components/tasks/create-task-dialog';19import { MobileTaskList } from '@/components/tasks/mobile-task-list';20import { IllusTasks } from '@/components/ui/illustrations';21import { cn } from '@/lib/cn';2223const TaskLog = dynamic(() => import('@/components/tasks/task-log'), { ssr: false, loading: () => <div className="flex h-full items-center justify-center"><Spinner /></div> });2425const durationSec = (t: AgentTask, now: number): number | null => (t.startedAt ? ((t.endedAt ? new Date(t.endedAt).getTime() : now) - new Date(t.startedAt).getTime()) / 1000 : null);2627function TasksInner() {28  const params = useSearchParams();29  const router = useRouter();30  const bp = useBreakpoint();31  const q = useTasks();32  const now = useNow(1000);33  const [creating, setCreating] = useState(false);34  const selected = params.get('t');35  const select = (id: string | null) => router.replace(id ? `/tasks/?t=${id}` : '/tasks/');36  const mobile = bp === 'mobile';37  const list = q.data ?? [];3839  const listPane = (40    <div className="flex h-full min-w-0 flex-col">41      <PageHeader title="Tasks" description="Background commands with captured logs. Nothing is retried automatically." actions={<Button variant="primary" onClick={() => setCreating(true)} data-testid="new-task"><Plus className="size-3.5" /> New task</Button>} />42      <div className="min-h-0 flex-1 overflow-y-auto" data-testid="task-list">43        {q.isPending ? <div className="flex flex-col gap-2 p-4">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-12" />)}</div>44        : q.isError ? <ErrorState error={q.error} onRetry={() => q.refetch()} />45        : list.length === 0 ? <EmptyState illustration={<IllusTasks />} title="No tasks yet" description="Run a build, a test suite or any long command in the background." />46        : (47          <ul className="divide-y divide-line">48            {list.map((t) => (49              <li key={t.id}>50                <button onClick={() => select(t.id)} aria-current={t.id === selected ? 'true' : undefined} data-testid="task-row" className={cn('flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-surface-2 md:px-6', t.id === selected && 'bg-surface-2')}>51                  <span className="min-w-0 flex-1">52                    <span className="block truncate text-[13px] font-medium text-text">{t.title}</span>53                    <span className="tnum block truncate text-[11px] text-muted">{t.host} · {fmtDuration(durationSec(t, now))}{t.exitCode != null ? ` · exit ${t.exitCode}` : ''}</span>54                  </span>55                  <TaskStateBadge state={t.state} />56                </button>57              </li>58            ))}59          </ul>60        )}61      </div>62    </div>63  );64  return (65    <>66      {mobile ? (selected ? <TaskDetail id={selected} onBack={() => select(null)} /> : <MobileTaskList onSelect={select} onNew={() => setCreating(true)} />) : (67        <div className="grid h-full grid-cols-[minmax(320px,2fr)_3fr]">68          <div className="min-w-0 border-r border-line">{listPane}</div>69          {selected ? <TaskDetail id={selected} /> : <EmptyState title="Select a task" description="Its live log, resource usage and artifacts appear here." className="h-full" />}70        </div>71      )}72      <CreateTaskDialog open={creating} onClose={() => setCreating(false)} onCreated={(t) => select(t.id)} />73    </>74  );75}7677function TaskDetail({ id, onBack }: { id: string; onBack?: () => void }) {78  const qc = useQueryClient();79  const router = useRouter();80  const q = useTask(id);81  const projects = useProjects().data;82  const now = useNow(1000);83  const [confirmCancel, setConfirmCancel] = useState(false);84  const [busy, setBusy] = useState(false);85  if (q.isPending) return <div className="flex h-full items-center justify-center"><Spinner /></div>;86  if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} className="h-full" />;87  const t = q.data;88  const active = TASK_ACTIVE.includes(t.state);89  const retry = async () => {90    setBusy(true);91    try {92      const n = await api.post<AgentTask>(`/tasks/${t.id}/retry`);93      upsertTask(qc, n);94      router.replace(`/tasks/?t=${n.id}`);95    } catch (e) {96      toast.error('Retry failed', errorMessage(e));97    } finally {98      setBusy(false);99    }100  };101  return (102    <div className="flex h-full min-w-0 flex-col" data-testid="task-detail">103      <div className="flex flex-wrap items-center gap-2 border-b border-line px-3 py-2.5 md:px-4">104        {onBack && <IconButton label="Back to tasks" noTooltip onClick={onBack}><ArrowLeft className="size-4" /></IconButton>}105        <h2 className="min-w-0 flex-1 truncate text-sm font-semibold">{t.title}</h2>106        <TaskStateBadge state={t.state} />107        {active && <Button size="sm" variant="danger" disabled={t.state === 'CANCELLING'} onClick={() => setConfirmCancel(true)} data-testid="cancel-task">Cancel</Button>}108        {!active && t.idempotent && <Button size="sm" loading={busy} onClick={retry} data-testid="retry-task">Retry</Button>}109      </div>110      <div className="border-b border-line px-3 py-2.5 md:px-4">111        <pre className="max-h-20 overflow-auto whitespace-pre-wrap break-all rounded-sm bg-surface-2 px-2.5 py-2 font-mono text-[11.5px] text-text-2">{t.command}</pre>112        <dl className="tnum mt-2.5 grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">113          {([114            ['Host', t.host],115            ['Project', projects?.find((p) => p.id === t.projectId)?.name ?? (t.projectId ? DASH : 'None')],116            ['Duration', fmtDuration(durationSec(t, now))],117            ['Exit code', t.exitCode ?? DASH],118            ['Started', fmtDateTime(t.startedAt)],119            ['Timeout', t.timeoutSec == null ? 'None' : fmtDuration(t.timeoutSec)],120            ['CPU time', t.cpuSec == null ? 'not measured' : `${fmtNum(t.cpuSec, 1)} s`],121            ['Peak memory', t.maxRssMb == null ? 'not measured' : `${fmtNum(t.maxRssMb, 0)} MB`],122          ] as const).map(([k, v]) => (123            <div key={k} className="min-w-0"><dt className="text-[11px] text-muted">{k}</dt><dd className="truncate text-text-2">{v}</dd></div>124          ))}125        </dl>126        {t.state === 'LOST' && <p className="mt-2 text-xs text-warning">The runner disappeared (host restart). The outcome is unknown; the task was not retried.</p>}127        {!t.idempotent && !active && <p className="mt-2 text-[11px] text-muted">Not marked idempotent: retry is not offered.</p>}128        {t.retryOf && <p className="mt-1 text-[11px] text-muted">Retry of <button className="text-accent hover:underline" onClick={() => router.replace(`/tasks/?t=${t.retryOf}`)}>a previous run</button>.</p>}129        {t.artifacts.length > 0 && (130          <ul className="mt-2.5 flex flex-wrap gap-2" aria-label="Artifacts">131            {t.artifacts.map((a) => (132              <li key={a.id}>133                <a href={`${API_BASE}/tasks/${t.id}/artifacts/${a.id}`} download={a.name} data-testid="artifact" className="inline-flex h-7 items-center gap-1.5 rounded-sm border border-line-2 bg-surface-2 px-2 text-xs hover:border-line-3 pointer-coarse:h-11">134                  <Download className="size-3" /> {a.name} <span className="tnum text-muted">{fmtBytes(a.size)}</span>135                </a>136              </li>137            ))}138          </ul>139        )}140      </div>141      <div className="min-h-0 flex-1"><TaskLog key={t.id} taskId={t.id} active={active} /></div>142      <ConfirmDialog143        open={confirmCancel}144        onOpenChange={setConfirmCancel}145        title="Cancel task?"146        description="The process group receives SIGTERM, then SIGKILL after 10 seconds. Partial output stays in the log."147        confirmLabel="Cancel task"148        danger149        onConfirm={async () => {150          try {151            await api.post(`/tasks/${t.id}/cancel`);152            // the final state may already have been pushed: refetch rather than trust the response order153            await Promise.all([qc.invalidateQueries({ queryKey: qk.tasks }), qc.invalidateQueries({ queryKey: qk.task(t.id) })]);154          } catch (e) {155            toast.error('Could not cancel', errorMessage(e));156          }157        }}158      />159    </div>160  );161}162export default function TasksPage() {163  return <Suspense><TasksInner /></Suspense>;164}165
Files main
Menu

Theme