Skip to content

Commit ed13129

Browse files
authored
Merge pull request #1843 from transformerlab/add/job-urls
Make each job shareable with permalink
2 parents 09243d1 + fb52d93 commit ed13129

18 files changed

Lines changed: 945 additions & 369 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,4 @@ test-results/
227227
**/.secrets
228228

229229
docs/superpowers/*
230+
.superpowers/

src/renderer/App.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,25 @@ function AppContent({
4949
const isInitialUserLoad =
5050
authContext.userIsLoading && !authContext.user && !authContext.userError;
5151

52+
useEffect(() => {
53+
if (authContext.isAuthenticated) {
54+
const redirect = localStorage.getItem('redirectAfterLogin');
55+
if (redirect) {
56+
localStorage.removeItem('redirectAfterLogin');
57+
window.location.hash = redirect;
58+
}
59+
}
60+
}, [authContext.isAuthenticated]);
61+
5262
if (authContext.initializing || isInitialUserLoad) {
5363
return <FullPageLoader />;
5464
}
5565

5666
if (!authContext?.isAuthenticated) {
67+
const currentHash = window.location.hash;
68+
if (currentHash && currentHash !== '#/' && currentHash !== '#') {
69+
localStorage.setItem('redirectAfterLogin', currentHash);
70+
}
5771
return <LoginPage />;
5872
}
5973

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import React from 'react';
2+
import { JobArtifactsBody } from '../Tasks/JobArtifacts/JobArtifactsModal';
3+
4+
export default function ArtifactsSection({ jobId }: { jobId: string }) {
5+
return <JobArtifactsBody jobId={jobId} showTitle={false} />;
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import React from 'react';
2+
import { CheckpointsBody } from '../Tasks/ViewCheckpointsModal';
3+
4+
export default function CheckpointsSection({ jobId }: { jobId: string }) {
5+
return <CheckpointsBody jobId={jobId} onRestartSuccess={() => {}} />;
6+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from 'react';
2+
import Typography from '@mui/joy/Typography';
3+
import { EvalResultsBody } from '../Tasks/ViewEvalResultsModal';
4+
5+
export default function EvalResultsSection({
6+
jobId,
7+
evalFiles,
8+
}: {
9+
jobId: string;
10+
evalFiles: string[];
11+
}) {
12+
if (evalFiles.length === 0) {
13+
return <Typography level="body-sm">No eval results available.</Typography>;
14+
}
15+
16+
return <EvalResultsBody jobId={jobId} enabled />;
17+
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { useParams, useNavigate } from 'react-router-dom';
3+
import Box from '@mui/joy/Box';
4+
import CircularProgress from '@mui/joy/CircularProgress';
5+
import Typography from '@mui/joy/Typography';
6+
import Chip from '@mui/joy/Chip';
7+
import IconButton from '@mui/joy/IconButton';
8+
import Tooltip from '@mui/joy/Tooltip';
9+
import List from '@mui/joy/List';
10+
import ListItem from '@mui/joy/ListItem';
11+
import ListItemButton from '@mui/joy/ListItemButton';
12+
import { ArrowLeftIcon, LinkIcon } from 'lucide-react';
13+
import { useExperimentInfo } from 'renderer/lib/ExperimentInfoContext';
14+
import { useSWRWithAuth } from 'renderer/lib/authContext';
15+
import * as chatAPI from 'renderer/lib/transformerlab-api-sdk';
16+
import { jobChipColor } from 'renderer/lib/utils';
17+
import {
18+
getDefaultSection,
19+
getVisibleSections,
20+
generateJobPermalink,
21+
type SectionKey,
22+
type JobRecord,
23+
} from './jobDetailUtils';
24+
import OverviewSection from './OverviewSection';
25+
import LogsSection from './LogsSection';
26+
import CheckpointsSection from './CheckpointsSection';
27+
import ArtifactsSection from './ArtifactsSection';
28+
import EvalResultsSection from './EvalResultsSection';
29+
import SweepResultsSection from './SweepResultsSection';
30+
31+
const SECTION_LABELS: Record<SectionKey, string> = {
32+
overview: 'Overview',
33+
logs: 'Logs',
34+
checkpoints: 'Checkpoints',
35+
artifacts: 'Artifacts',
36+
evalResults: 'Eval Results',
37+
sweepResults: 'Sweep Results',
38+
};
39+
40+
export default function JobDetailPage() {
41+
const { experimentName = '', jobId = '' } = useParams<{
42+
experimentName: string;
43+
jobId: string;
44+
}>();
45+
const navigate = useNavigate();
46+
const { experimentInfo, setExperimentId } = useExperimentInfo();
47+
48+
useEffect(() => {
49+
if (experimentName) setExperimentId(experimentName);
50+
}, [experimentName, setExperimentId]);
51+
52+
const {
53+
data: jobData,
54+
isError,
55+
isLoading: jobLoading,
56+
} = useSWRWithAuth(
57+
experimentInfo?.id && jobId
58+
? chatAPI.Endpoints.Jobs.Get(experimentInfo.id, jobId)
59+
: null,
60+
);
61+
62+
const job: JobRecord | null = jobData ?? null;
63+
const visibleSections: SectionKey[] = job
64+
? getVisibleSections(job)
65+
: ['overview', 'logs'];
66+
const [activeSection, setActiveSection] = useState<SectionKey | null>(null);
67+
68+
const effectiveSection: SectionKey =
69+
activeSection ?? (job ? getDefaultSection(job.status ?? '') : 'overview');
70+
71+
if (!experimentInfo) {
72+
return (
73+
<Box
74+
sx={{
75+
display: 'flex',
76+
justifyContent: 'center',
77+
alignItems: 'center',
78+
height: '100%',
79+
}}
80+
>
81+
<CircularProgress />
82+
</Box>
83+
);
84+
}
85+
86+
if (isError) {
87+
return (
88+
<Box sx={{ p: 4 }}>
89+
<Typography level="h4" color="danger">
90+
Job not found
91+
</Typography>
92+
<Typography
93+
sx={{ mt: 1, cursor: 'pointer', textDecoration: 'underline' }}
94+
onClick={() => navigate(`/experiment/${experimentName}/tasks`)}
95+
>
96+
Back to Tasks
97+
</Typography>
98+
</Box>
99+
);
100+
}
101+
102+
if (jobLoading) {
103+
return (
104+
<Box
105+
sx={{
106+
display: 'flex',
107+
justifyContent: 'center',
108+
alignItems: 'center',
109+
height: '100%',
110+
}}
111+
>
112+
<CircularProgress />
113+
</Box>
114+
);
115+
}
116+
117+
return (
118+
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
119+
{/* Top bar */}
120+
<Box
121+
sx={{
122+
display: 'flex',
123+
alignItems: 'center',
124+
justifyContent: 'space-between',
125+
px: 2,
126+
py: 1,
127+
borderBottom: '1px solid',
128+
borderColor: 'divider',
129+
flexShrink: 0,
130+
}}
131+
>
132+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
133+
<Tooltip title={`Back to ${experimentName} tasks`}>
134+
<IconButton
135+
size="sm"
136+
variant="plain"
137+
onClick={() => navigate(`/experiment/${experimentName}/tasks`)}
138+
>
139+
<ArrowLeftIcon size={16} />
140+
</IconButton>
141+
</Tooltip>
142+
<Typography level="title-sm" sx={{ color: 'text.secondary' }}>
143+
{experimentName}
144+
</Typography>
145+
<Typography level="title-sm" sx={{ color: 'text.tertiary' }}>
146+
/
147+
</Typography>
148+
<Typography level="title-sm">
149+
Job {job?.id ? job.id.slice(0, 8) : jobId.slice(0, 8)}
150+
</Typography>
151+
{job?.type && (
152+
<Chip size="sm" color="primary" variant="soft">
153+
{job.type}
154+
</Chip>
155+
)}
156+
{job?.status && (
157+
<Chip
158+
size="sm"
159+
color={jobChipColor(job.status) as any}
160+
variant="soft"
161+
>
162+
{job.status}
163+
</Chip>
164+
)}
165+
</Box>
166+
<Tooltip title="Copy permalink">
167+
<IconButton
168+
size="sm"
169+
variant="plain"
170+
color="neutral"
171+
onClick={() => {
172+
navigator.clipboard
173+
.writeText(
174+
window.location.href.split('#')[0] +
175+
generateJobPermalink(experimentName, jobId),
176+
)
177+
.catch((err) =>
178+
console.error('Failed to copy permalink:', err),
179+
);
180+
}}
181+
>
182+
<LinkIcon size={16} />
183+
</IconButton>
184+
</Tooltip>
185+
</Box>
186+
187+
{/* Body: sidebar + content */}
188+
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
189+
{/* Sidebar */}
190+
<Box
191+
sx={{
192+
width: 160,
193+
flexShrink: 0,
194+
borderRight: '1px solid',
195+
borderColor: 'divider',
196+
overflowY: 'auto',
197+
}}
198+
>
199+
<List size="sm" sx={{ py: 1 }}>
200+
{visibleSections.map((key) => (
201+
<ListItem key={key}>
202+
<ListItemButton
203+
selected={effectiveSection === key}
204+
onClick={() => setActiveSection(key)}
205+
>
206+
{SECTION_LABELS[key]}
207+
</ListItemButton>
208+
</ListItem>
209+
))}
210+
</List>
211+
</Box>
212+
213+
{/* Main content */}
214+
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
215+
{effectiveSection === 'overview' && <OverviewSection job={job} />}
216+
{effectiveSection === 'logs' && (
217+
<LogsSection jobId={jobId} jobStatus={job?.status ?? ''} />
218+
)}
219+
{effectiveSection === 'checkpoints' && (
220+
<CheckpointsSection jobId={jobId} />
221+
)}
222+
{effectiveSection === 'artifacts' && (
223+
<ArtifactsSection jobId={jobId} />
224+
)}
225+
{effectiveSection === 'evalResults' && (
226+
<EvalResultsSection
227+
jobId={jobId}
228+
evalFiles={job?.job_data?.eval_results ?? []}
229+
/>
230+
)}
231+
{effectiveSection === 'sweepResults' && (
232+
<SweepResultsSection jobId={jobId} />
233+
)}
234+
</Box>
235+
</Box>
236+
</Box>
237+
);
238+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from 'react';
2+
import EmbeddableStreamingOutput from '../Tasks/EmbeddableStreamingOutput';
3+
4+
export default function LogsSection({
5+
jobId,
6+
jobStatus,
7+
}: {
8+
jobId: string;
9+
jobStatus: string;
10+
}) {
11+
return (
12+
<EmbeddableStreamingOutput
13+
jobId={jobId}
14+
jobStatus={jobStatus}
15+
tabs={['output', 'provider']}
16+
/>
17+
);
18+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from 'react';
2+
import Box from '@mui/joy/Box';
3+
import Typography from '@mui/joy/Typography';
4+
import Divider from '@mui/joy/Divider';
5+
import CircularProgress from '@mui/joy/CircularProgress';
6+
import { JobRecord } from './jobDetailUtils';
7+
8+
function Row({ label, value }: { label: string; value: React.ReactNode }) {
9+
return (
10+
<Box sx={{ display: 'flex', gap: 2, py: 0.75 }}>
11+
<Typography
12+
level="body-sm"
13+
sx={{ width: 160, flexShrink: 0, color: 'text.secondary' }}
14+
>
15+
{label}
16+
</Typography>
17+
<Typography level="body-sm">{value ?? '—'}</Typography>
18+
</Box>
19+
);
20+
}
21+
22+
export default function OverviewSection({ job }: { job: JobRecord | null }) {
23+
if (!job) {
24+
return (
25+
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 4 }}>
26+
<CircularProgress />
27+
</Box>
28+
);
29+
}
30+
31+
const d = job.job_data ?? {};
32+
const createdAt = job.created_at
33+
? new Date(job.created_at).toLocaleString()
34+
: null;
35+
36+
return (
37+
<Box sx={{ maxWidth: 600 }}>
38+
<Typography level="title-md" sx={{ mb: 1 }}>
39+
Job Details
40+
</Typography>
41+
<Divider sx={{ mb: 2 }} />
42+
<Row label="Job ID" value={job.id} />
43+
<Row label="Status" value={job.status} />
44+
<Row label="Type" value={job.type} />
45+
<Row label="Created" value={createdAt} />
46+
{d.provider_name && (
47+
<Row label="Provider" value={String(d.provider_name)} />
48+
)}
49+
{d.cluster_name && <Row label="Cluster" value={String(d.cluster_name)} />}
50+
{(d as any).template_name && (
51+
<Row label="Template" value={String((d as any).template_name)} />
52+
)}
53+
{typeof job.progress === 'number' && (
54+
<Row label="Progress" value={`${job.progress}%`} />
55+
)}
56+
{d.error_msg && (
57+
<>
58+
<Divider sx={{ my: 2 }} />
59+
<Typography level="title-sm" color="danger" sx={{ mb: 1 }}>
60+
Error
61+
</Typography>
62+
<Typography
63+
level="body-sm"
64+
sx={{
65+
fontFamily: 'monospace',
66+
whiteSpace: 'pre-wrap',
67+
background: 'var(--joy-palette-background-surface)',
68+
p: 1.5,
69+
borderRadius: 'sm',
70+
}}
71+
>
72+
{String(d.error_msg)}
73+
</Typography>
74+
</>
75+
)}
76+
</Box>
77+
);
78+
}

0 commit comments

Comments
 (0)