Skip to content

Commit 2ad4cb8

Browse files
authored
Merge branch 'main' into fix/recipe-modal-popup
2 parents 7f476af + 46d2ead commit 2ad4cb8

File tree

5 files changed

+289
-55
lines changed

5 files changed

+289
-55
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const JobsList: React.FC<JobsListProps> = ({
5252
<th style={{ width: '60px' }}>ID</th>
5353
<th>Details</th>
5454
<th>Status</th>
55-
<th style={{ width: '400px' }}>Other</th>
55+
<th style={{ width: '400px' }}></th>
5656
</tr>
5757
</thead>
5858
<tbody style={{ overflow: 'auto', height: '100%' }}>
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import React, { useRef, useEffect, useState, useCallback } from 'react';
2+
import { Terminal } from '@xterm/xterm';
3+
import { FitAddon } from '@xterm/addon-fit';
4+
import { Sheet } from '@mui/joy';
5+
import useSWR from 'swr';
6+
import * as chatAPI from 'renderer/lib/transformerlab-api-sdk';
7+
8+
const debounce = (func: (...args: any[]) => void, wait: number) => {
9+
let timeout: NodeJS.Timeout;
10+
return (...args: any[]) => {
11+
clearTimeout(timeout);
12+
timeout = setTimeout(() => func.apply(this, args), wait);
13+
};
14+
};
15+
16+
interface PollingOutputTerminalProps {
17+
jobId: number;
18+
experimentId: string;
19+
lineAnimationDelay?: number;
20+
initialMessage?: string;
21+
refreshInterval?: number;
22+
}
23+
24+
const PollingOutputTerminal: React.FC<PollingOutputTerminalProps> = ({
25+
jobId,
26+
experimentId,
27+
lineAnimationDelay = 10,
28+
initialMessage = '',
29+
refreshInterval = 2000, // Poll every 2 seconds
30+
}) => {
31+
const terminalRef = useRef<HTMLDivElement | null>(null);
32+
const termRef = useRef<Terminal | null>(null);
33+
const fitAddon = useRef(new FitAddon());
34+
const lineQueue = useRef<string[]>([]);
35+
const isProcessing = useRef(false);
36+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
37+
const [lastContent, setLastContent] = useState<string>('');
38+
39+
const handleResize = useCallback(
40+
debounce(() => {
41+
if (termRef.current) {
42+
fitAddon.current.fit();
43+
}
44+
}, 300),
45+
[],
46+
);
47+
48+
const processQueue = () => {
49+
if (!termRef.current) return;
50+
if (lineQueue.current.length === 0) {
51+
isProcessing.current = false;
52+
return;
53+
}
54+
55+
isProcessing.current = true;
56+
const line = lineQueue.current.shift()!;
57+
58+
try {
59+
// Write the line and add a newline
60+
termRef.current.write(line + '\r\n');
61+
} catch (error) {
62+
console.error('PollingOutputTerminal: Error writing to terminal:', error);
63+
}
64+
65+
if (terminalRef.current) {
66+
terminalRef.current.scrollIntoView({ behavior: 'smooth' });
67+
}
68+
69+
timeoutRef.current = setTimeout(() => {
70+
processQueue();
71+
}, lineAnimationDelay);
72+
};
73+
74+
// Fetch the output file content directly using the Tasks-specific endpoint
75+
const outputEndpoint = chatAPI.Endpoints.Experiment.GetTasksOutputFromJob(
76+
experimentId,
77+
jobId.toString(),
78+
);
79+
80+
const { data: outputData, error } = useSWR(
81+
outputEndpoint,
82+
async (url: string) => {
83+
const response = await chatAPI.authenticatedFetch(url);
84+
if (!response.ok) {
85+
throw new Error(`HTTP ${response.status}`);
86+
}
87+
return response.json();
88+
},
89+
{
90+
refreshInterval: refreshInterval,
91+
revalidateOnFocus: false,
92+
revalidateOnReconnect: false,
93+
errorRetryCount: 3,
94+
errorRetryInterval: 5000,
95+
},
96+
);
97+
98+
// Terminal initialization (only once)
99+
useEffect(() => {
100+
termRef.current = new Terminal({
101+
smoothScrollDuration: 200,
102+
});
103+
termRef.current.loadAddon(fitAddon.current);
104+
105+
if (terminalRef.current) {
106+
termRef.current.open(terminalRef.current);
107+
}
108+
109+
fitAddon.current.fit();
110+
111+
const resizeObserver = new ResizeObserver(() => {
112+
handleResize();
113+
});
114+
115+
if (terminalRef.current) {
116+
resizeObserver.observe(terminalRef.current);
117+
}
118+
119+
if (initialMessage) {
120+
termRef.current.writeln(initialMessage);
121+
}
122+
123+
return () => {
124+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
125+
termRef.current?.dispose();
126+
termRef.current = null;
127+
if (terminalRef.current) {
128+
resizeObserver.unobserve(terminalRef.current);
129+
}
130+
resizeObserver.disconnect();
131+
};
132+
}, []); // Only run once on mount
133+
134+
// Data processing (separate useEffect)
135+
useEffect(() => {
136+
if (!termRef.current) return;
137+
138+
const addLinesOneByOne = (lines: string[]) => {
139+
lineQueue.current = lineQueue.current.concat(lines);
140+
if (!isProcessing.current) {
141+
processQueue();
142+
}
143+
};
144+
145+
// Process new content when data changes
146+
if (outputData && Array.isArray(outputData)) {
147+
const currentLines = outputData.join('\n');
148+
if (currentLines !== lastContent) {
149+
// Only process new lines (content that wasn't there before)
150+
if (lastContent) {
151+
const newContent = currentLines.slice(lastContent.length);
152+
if (newContent.trim()) {
153+
// Split new content by newlines
154+
const newLines = newContent
155+
.split('\n')
156+
.filter((line) => line.trim());
157+
addLinesOneByOne(newLines);
158+
}
159+
} else {
160+
// First time - clear the loading message and add all content
161+
if (termRef.current) {
162+
termRef.current.clear();
163+
}
164+
addLinesOneByOne(outputData);
165+
}
166+
167+
setLastContent(currentLines);
168+
}
169+
}
170+
171+
// Handle errors
172+
if (error) {
173+
const errorMessage = `Error fetching output: ${error.message}`;
174+
addLinesOneByOne([errorMessage]);
175+
}
176+
}, [outputData, lastContent, error]);
177+
178+
return (
179+
<Sheet
180+
sx={{
181+
overflow: 'auto',
182+
backgroundColor: '#000',
183+
color: '#aaa',
184+
height: '100%',
185+
}}
186+
ref={terminalRef}
187+
>
188+
&nbsp;
189+
</Sheet>
190+
);
191+
};
192+
193+
export default PollingOutputTerminal;

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

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

44
import { Button, LinearProgress, Stack, Typography } from '@mui/joy';
55

66
import dayjs from 'dayjs';
77
import relativeTime from 'dayjs/plugin/relativeTime';
88
import { PlusIcon } from 'lucide-react';
9+
import useSWR from 'swr';
910

1011
import * as chatAPI from 'renderer/lib/transformerlab-api-sdk';
1112
import { useExperimentInfo } from 'renderer/lib/ExperimentInfoContext';
13+
import { fetcher } from 'renderer/lib/transformerlab-api-sdk';
1214
import TaskTemplateList from './TaskTemplateList';
1315
import JobsList from './JobsList';
1416
import NewTaskModal from './NewTaskModal';
17+
import ViewOutputModalStreaming from './ViewOutputModalStreaming';
1518

1619
const duration = require('dayjs/plugin/duration');
1720

@@ -21,67 +24,54 @@ dayjs.extend(relativeTime);
2124
export default function Tasks() {
2225
const [modalOpen, setModalOpen] = useState(false);
2326
const [isSubmitting, setIsSubmitting] = useState(false);
24-
const [tasks, setTasks] = useState([]);
25-
const [jobs, setJobs] = useState([]);
26-
const [loading, setLoading] = useState(false);
27+
const [viewOutputFromJob, setViewOutputFromJob] = useState(-1);
2728
const [currentTensorboardForModal, setCurrentTensorboardForModal] =
2829
useState(-1);
29-
const [viewOutputFromJob, setViewOutputFromJob] = useState(-1);
30-
const [viewOutputFromSweepJob, setViewOutputFromSweepJob] = useState(false);
31-
const [viewEvalImagesFromJob, setViewEvalImagesFromJob] = useState(-1);
3230
const [viewCheckpointsFromJob, setViewCheckpointsFromJob] = useState(-1);
31+
const [viewEvalImagesFromJob, setViewEvalImagesFromJob] = useState(-1);
32+
const [viewOutputFromSweepJob, setViewOutputFromSweepJob] = useState(false);
3333
const { experimentInfo } = useExperimentInfo();
3434

3535
const handleOpen = () => setModalOpen(true);
3636
const handleClose = () => setModalOpen(false);
3737

38-
const fetchTasks = async () => {
39-
if (!experimentInfo?.id) return;
40-
41-
try {
42-
const response = await chatAPI.authenticatedFetch(
43-
chatAPI.Endpoints.Tasks.List(),
44-
);
45-
const data = await response.json();
46-
47-
// Filter for remote tasks in this experiment only
48-
const remoteTasks = data.filter(
49-
(task: any) =>
50-
task.remote_task === true && task.experiment_id === experimentInfo.id,
51-
);
52-
setTasks(remoteTasks);
53-
} catch (error) {
54-
// eslint-disable-next-line no-console
55-
console.error('Error fetching tasks:', error);
56-
}
57-
};
58-
59-
const fetchJobs = async () => {
60-
if (!experimentInfo?.id) return;
38+
// Fetch tasks with useSWR
39+
const {
40+
data: allTasks,
41+
error: tasksError,
42+
isLoading: tasksIsLoading,
43+
mutate: tasksMutate,
44+
} = useSWR(
45+
experimentInfo?.id ? chatAPI.Endpoints.Tasks.List() : null,
46+
fetcher,
47+
);
6148

62-
try {
63-
const response = await chatAPI.authenticatedFetch(
64-
chatAPI.Endpoints.Jobs.GetJobsOfType(experimentInfo.id, 'REMOTE', ''),
65-
);
66-
const data = await response.json();
67-
setJobs(data);
68-
} catch (error) {
69-
// eslint-disable-next-line no-console
70-
console.error('Error fetching jobs:', error);
71-
}
72-
};
49+
// Filter tasks for remote tasks in this experiment only
50+
const tasks =
51+
allTasks?.filter(
52+
(task: any) =>
53+
task.remote_task === true && task.experiment_id === experimentInfo?.id,
54+
) || [];
7355

74-
const fetchData = useCallback(async () => {
75-
setLoading(true);
76-
await Promise.all([fetchTasks(), fetchJobs()]);
77-
setLoading(false);
78-
}, [experimentInfo?.id]);
56+
// Fetch jobs with automatic polling
57+
const {
58+
data: jobs,
59+
error: jobsError,
60+
isLoading: jobsIsLoading,
61+
mutate: jobsMutate,
62+
} = useSWR(
63+
experimentInfo?.id
64+
? chatAPI.Endpoints.Jobs.GetJobsOfType(experimentInfo.id, 'REMOTE', '')
65+
: null,
66+
fetcher,
67+
{
68+
refreshInterval: 3000, // Poll every 3 seconds for job status updates
69+
revalidateOnFocus: false, // Don't refetch when window regains focus
70+
revalidateOnReconnect: true, // Refetch when network reconnects
71+
},
72+
);
7973

80-
useEffect(() => {
81-
if (experimentInfo?.id) {
82-
fetchData();
83-
}
84-
}, [experimentInfo?.id, fetchData]);
74+
const loading = tasksIsLoading || jobsIsLoading;
8575

8676
const handleDeleteTask = async (taskId: string) => {
8777
if (!experimentInfo?.id) return;
@@ -103,7 +93,7 @@ export default function Tasks() {
10393
// eslint-disable-next-line no-alert
10494
alert('Task deleted successfully!');
10595
// Refresh the data to remove the deleted task
106-
await fetchData();
96+
await tasksMutate();
10797
} else {
10898
// eslint-disable-next-line no-alert
10999
alert('Failed to delete task. Please try again.');
@@ -136,7 +126,7 @@ export default function Tasks() {
136126
// eslint-disable-next-line no-alert
137127
alert('Job deleted successfully!');
138128
// Refresh the data to remove the deleted job
139-
await fetchData();
129+
await jobsMutate();
140130
} else {
141131
// eslint-disable-next-line no-alert
142132
alert('Failed to delete job. Please try again.');
@@ -189,7 +179,7 @@ export default function Tasks() {
189179
alert('Task launched successfully!');
190180
setModalOpen(false);
191181
// Refresh the data to show the new task and job
192-
await fetchData();
182+
await Promise.all([tasksMutate(), jobsMutate()]);
193183
} else {
194184
// eslint-disable-next-line no-alert
195185
alert(`Error: ${result.message}`);
@@ -272,6 +262,10 @@ export default function Tasks() {
272262
/>
273263
)}
274264
</Sheet>
265+
<ViewOutputModalStreaming
266+
jobId={viewOutputFromJob}
267+
setJobId={(jobId: number) => setViewOutputFromJob(jobId)}
268+
/>
275269
</Sheet>
276270
);
277271
}

0 commit comments

Comments
 (0)