Skip to content

Commit ea7ab09

Browse files
authored
Merge pull request transformerlab#842 from transformerlab/fix/jobs-placeholder
Add jobs placeholder skeleton until the new job id is included in the jobs list results
2 parents 8262336 + fc02826 commit ea7ab09

File tree

3 files changed

+144
-28
lines changed

3 files changed

+144
-28
lines changed

src/renderer/components/Experiment/Tasks/JobProgress.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Stack,
77
Typography,
88
} from '@mui/joy';
9+
import Skeleton from '@mui/joy/Skeleton';
910
import { CircleCheckIcon, StopCircleIcon } from 'lucide-react';
1011
import dayjs from 'dayjs';
1112
import relativeTime from 'dayjs/plugin/relativeTime';
@@ -37,6 +38,7 @@ interface JobProps {
3738
status: string;
3839
progress: string | number;
3940
job_data?: JobData;
41+
placeholder?: boolean;
4042
};
4143
}
4244

@@ -106,9 +108,6 @@ export default function JobProgress({ job }: JobProps) {
106108
startLogPolling,
107109
]);
108110

109-
// Debug job data
110-
useEffect(() => {}, [job]);
111-
112111
// Ensure progress is a number
113112
const progress = (() => {
114113
if (typeof job?.progress === 'string') {
@@ -122,7 +121,22 @@ export default function JobProgress({ job }: JobProps) {
122121

123122
return (
124123
<Stack>
125-
{job?.status === 'LAUNCHING' ? (
124+
{job?.placeholder ? (
125+
<>
126+
<Stack direction="row" alignItems="center" gap={1}>
127+
<Chip>
128+
<Skeleton variant="text" level="body-xs" width={60} />
129+
</Chip>
130+
</Stack>
131+
<Skeleton variant="text" level="body-sm" width={180} />
132+
<Skeleton
133+
variant="rectangular"
134+
width={220}
135+
height={10}
136+
sx={{ my: 0.5 }}
137+
/>
138+
</>
139+
) : job?.status === 'LAUNCHING' ? (
126140
<>
127141
<Stack direction="row" alignItems="center" gap={1}>
128142
<Chip

src/renderer/components/Experiment/Tasks/JobsList.tsx

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Table from '@mui/joy/Table';
33
import ButtonGroup from '@mui/joy/ButtonGroup';
44
import IconButton from '@mui/joy/IconButton';
55
import Button from '@mui/joy/Button';
6+
import Skeleton from '@mui/joy/Skeleton';
67
import Box from '@mui/joy/Box';
78
import {
89
Trash2Icon,
@@ -36,6 +37,14 @@ const JobsList: React.FC<JobsListProps> = ({
3637
onViewSweepOutput,
3738
}) => {
3839
const formatJobConfig = (job: any) => {
40+
if (job?.placeholder) {
41+
return (
42+
<>
43+
<Skeleton variant="text" level="body-md" width={160} />
44+
<Skeleton variant="text" level="body-sm" width={100} />
45+
</>
46+
);
47+
}
3948
// For jobs with template name, show template info
4049
if (job.job_data?.template_name) {
4150
return (
@@ -67,19 +76,23 @@ const JobsList: React.FC<JobsListProps> = ({
6776
<td>
6877
<b>{job.id}</b>
6978
<br />
70-
<InfoIcon
71-
onClick={() => {
72-
const jobDataConfig = job?.job_data;
73-
if (typeof jobDataConfig === 'object') {
74-
alert(JSON.stringify(jobDataConfig, null, 2));
75-
} else {
76-
alert(jobDataConfig);
77-
}
78-
}}
79-
size="16px"
80-
color="var(--joy-palette-neutral-500)"
81-
style={{ cursor: 'pointer' }}
82-
/>
79+
{job?.placeholder ? (
80+
<Skeleton variant="text" level="body-xs" width={60} />
81+
) : (
82+
<InfoIcon
83+
onClick={() => {
84+
const jobDataConfig = job?.job_data;
85+
if (typeof jobDataConfig === 'object') {
86+
alert(JSON.stringify(jobDataConfig, null, 2));
87+
} else {
88+
alert(jobDataConfig);
89+
}
90+
}}
91+
size="16px"
92+
color="var(--joy-palette-neutral-500)"
93+
style={{ cursor: 'pointer' }}
94+
/>
95+
)}
8396
</td>
8497
<td>{formatJobConfig(job)}</td>
8598
<td>
@@ -89,6 +102,11 @@ const JobsList: React.FC<JobsListProps> = ({
89102
<ButtonGroup
90103
sx={{ justifyContent: 'flex-end', flexWrap: 'wrap' }}
91104
>
105+
{job?.placeholder && (
106+
<>
107+
<Skeleton variant="rectangular" width={100} height={28} />
108+
</>
109+
)}
92110
{job?.job_data?.tensorboard_output_dir && (
93111
<Button
94112
size="sm"
@@ -203,12 +221,14 @@ const JobsList: React.FC<JobsListProps> = ({
203221
</Box>
204222
</Button>
205223
)}
206-
<IconButton variant="plain">
207-
<Trash2Icon
208-
onClick={() => onDeleteJob?.(job.id)}
209-
style={{ cursor: 'pointer' }}
210-
/>
211-
</IconButton>
224+
{!job?.placeholder && (
225+
<IconButton variant="plain">
226+
<Trash2Icon
227+
onClick={() => onDeleteJob?.(job.id)}
228+
style={{ cursor: 'pointer' }}
229+
/>
230+
</IconButton>
231+
)}
212232
</ButtonGroup>
213233
</td>
214234
</tr>

src/renderer/components/Experiment/Tasks/Tasks.tsx

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useCallback } from 'react';
1+
import React, { useState, useCallback, useMemo, useEffect } from 'react';
22
import Sheet from '@mui/joy/Sheet';
33

44
import { Button, LinearProgress, Stack, Typography } from '@mui/joy';
@@ -40,6 +40,44 @@ export default function Tasks() {
4040
const { experimentInfo } = useExperimentInfo();
4141
const { addNotification } = useNotification();
4242

43+
// Pending job IDs persisted per experiment to show immediate placeholders
44+
const pendingJobsStorageKey = useMemo(
45+
() =>
46+
experimentInfo?.id
47+
? `pendingJobIds:${String(experimentInfo.id)}`
48+
: 'pendingJobIds:unknown',
49+
[experimentInfo?.id],
50+
);
51+
useEffect(() => {
52+
// Debug storage key per experiment
53+
// eslint-disable-next-line no-console
54+
}, [pendingJobsStorageKey]);
55+
56+
const getPendingJobIds = useCallback((): string[] => {
57+
try {
58+
const raw = window.localStorage.getItem(pendingJobsStorageKey);
59+
if (!raw) return [];
60+
const parsed = JSON.parse(raw);
61+
const result = Array.isArray(parsed) ? parsed : [];
62+
// eslint-disable-next-line no-console
63+
return result;
64+
} catch {
65+
return [];
66+
}
67+
}, [pendingJobsStorageKey]);
68+
69+
const setPendingJobIds = useCallback(
70+
(ids: string[]) => {
71+
try {
72+
window.localStorage.setItem(pendingJobsStorageKey, JSON.stringify(ids));
73+
// eslint-disable-next-line no-console
74+
} catch {
75+
// ignore storage failures
76+
}
77+
},
78+
[pendingJobsStorageKey],
79+
);
80+
4381
const handleOpen = () => setModalOpen(true);
4482
const handleClose = () => setModalOpen(false);
4583
const handleEditClose = () => {
@@ -104,6 +142,45 @@ export default function Tasks() {
104142

105143
const loading = tasksIsLoading || jobsIsLoading;
106144

145+
// Remove any pending placeholders that are now present in jobs
146+
useEffect(() => {
147+
if (!jobs || !Array.isArray(jobs)) return;
148+
const pending = getPendingJobIds();
149+
if (pending.length === 0) return;
150+
const existingIds = new Set((jobs as any[]).map((j: any) => String(j.id)));
151+
const stillPending = pending.filter((id) => !existingIds.has(String(id)));
152+
// eslint-disable-next-line no-console
153+
console.log('[Tasks] prune pending vs jobs', {
154+
jobsCount: jobs?.length,
155+
pending,
156+
stillPending,
157+
});
158+
if (stillPending.length !== pending.length) {
159+
setPendingJobIds(stillPending);
160+
}
161+
}, [jobs, getPendingJobIds, setPendingJobIds]);
162+
163+
// Build list with placeholders for pending job IDs not yet in jobs
164+
const jobsWithPlaceholders = useMemo(() => {
165+
const baseJobs = Array.isArray(jobs) ? jobs : [];
166+
const pending = getPendingJobIds();
167+
if (!pending.length) return baseJobs;
168+
const existingIds = new Set(baseJobs.map((j: any) => String(j.id)));
169+
const placeholders = pending
170+
.filter((id) => !existingIds.has(String(id)))
171+
.map((id) => ({
172+
id: String(id),
173+
type: 'REMOTE',
174+
status: 'CREATED',
175+
progress: 0,
176+
job_data: {},
177+
placeholder: true,
178+
}));
179+
// Show newest first consistent with existing ordering if any
180+
const combined = [...placeholders, ...baseJobs];
181+
return combined;
182+
}, [jobs, getPendingJobIds]);
183+
107184
const handleDeleteTask = async (taskId: string) => {
108185
if (!experimentInfo?.id) return;
109186

@@ -296,9 +373,14 @@ export default function Tasks() {
296373
const createJobResult = await createJobResp.json();
297374

298375
if (createJobResult.status === 'success') {
299-
// Keep placeholder visible and refresh jobs list
300-
// The placeholder will be replaced when the real job appears
301-
await jobsMutate();
376+
// Persist pending placeholder immediately so it shows up in the UI
377+
const newId = String(createJobResult.job_id);
378+
const pending = getPendingJobIds();
379+
if (!pending.includes(newId)) {
380+
setPendingJobIds([newId, ...pending]);
381+
}
382+
// IMPORTANT: Don't await jobsMutate, let UI update before real jobs arrive
383+
setTimeout(() => jobsMutate(), 0);
302384

303385
addNotification({
304386
type: 'success',
@@ -412,7 +494,7 @@ export default function Tasks() {
412494
<LinearProgress />
413495
) : (
414496
<JobsList
415-
jobs={jobs}
497+
jobs={jobsWithPlaceholders as any}
416498
onDeleteJob={handleDeleteJob}
417499
onViewOutput={(jobId) => setViewOutputFromJob(parseInt(jobId))}
418500
onViewTensorboard={(jobId) =>

0 commit comments

Comments
 (0)