Skip to content

Commit ce4735a

Browse files
[UI] Add Connect section for tasks with ports and fix launch wizard issues
Add a Connect component for task runs that expose ports, with a two-step wizard (Attach + Open). Single port shows a simple button; multiple ports use a ButtonDropdown to select and open any forwarded port. Also fix two launch wizard issues: - Add `ports` to the supported YAML fields whitelist - Stop setting `docker: true` when selecting a Docker image (unrelated field) - Fix `IJobSpec.app_specs` type from singular to array - Show `-` for empty configuration path on run details Made-with: Cursor
1 parent 84e2c70 commit ce4735a

6 files changed

Lines changed: 263 additions & 5 deletions

File tree

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import React, { FC } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
4+
import {
5+
Alert,
6+
Box,
7+
Button,
8+
ButtonDropdown,
9+
Code,
10+
Container,
11+
ExpandableSection,
12+
Header,
13+
Popover,
14+
SpaceBetween,
15+
StatusIndicator,
16+
Tabs,
17+
Wizard,
18+
} from 'components';
19+
20+
import { copyToClipboard } from 'libs';
21+
22+
import { useConfigProjectCliCommand } from 'pages/Project/hooks/useConfigProjectCliComand';
23+
24+
import styles from '../ConnectToRunWithDevEnvConfiguration/styles.module.scss';
25+
26+
const UvInstallCommand = 'uv tool install dstack -U';
27+
const PipInstallCommand = 'pip install dstack -U';
28+
29+
const getPort = (spec: IAppSpec): number => spec.map_to_port ?? spec.port;
30+
31+
export const ConnectToTaskRun: FC<{ run: IRun }> = ({ run }) => {
32+
const { t } = useTranslation();
33+
34+
const attachCommand = `dstack attach ${run.run_spec.run_name} --logs`;
35+
const appSpecs = run.jobs[0]?.job_spec?.app_specs ?? [];
36+
37+
const [activeStepIndex, setActiveStepIndex] = React.useState(0);
38+
const [selectedPort, setSelectedPort] = React.useState(() => getPort(appSpecs[0]));
39+
const [configCliCommand, copyCliCommand] = useConfigProjectCliCommand({ projectName: run.project_name });
40+
41+
const openPort = (port: number) => window.open(`http://127.0.0.1:${port}`, '_blank');
42+
43+
return (
44+
<Container>
45+
<Header variant="h2">Connect</Header>
46+
47+
{run.status === 'running' && (
48+
<Wizard
49+
i18nStrings={{
50+
stepNumberLabel: (stepNumber) => `Step ${stepNumber}`,
51+
collapsedStepsLabel: (stepNumber, stepsCount) => `Step ${stepNumber} of ${stepsCount}`,
52+
skipToButtonLabel: (step) => `Skip to ${step.title}`,
53+
navigationAriaLabel: 'Steps',
54+
previousButton: 'Previous',
55+
nextButton: 'Next',
56+
optional: 'required',
57+
}}
58+
onNavigate={({ detail }) => setActiveStepIndex(detail.requestedStepIndex)}
59+
activeStepIndex={activeStepIndex}
60+
onSubmit={() => openPort(selectedPort)}
61+
submitButtonText={appSpecs.length === 1 ? 'Open port' : `Open port ${selectedPort}`}
62+
allowSkipTo
63+
steps={[
64+
{
65+
title: 'Attach',
66+
content: (
67+
<SpaceBetween size="s">
68+
<Box>To access this run, first you need to attach to it.</Box>
69+
<div className={styles.codeWrapper}>
70+
<Code className={styles.code}>{attachCommand}</Code>
71+
72+
<div className={styles.copy}>
73+
<Popover
74+
dismissButton={false}
75+
position="top"
76+
size="small"
77+
triggerType="custom"
78+
content={<StatusIndicator type="success">{t('common.copied')}</StatusIndicator>}
79+
>
80+
<Button
81+
formAction="none"
82+
iconName="copy"
83+
variant="normal"
84+
onClick={() => copyToClipboard(attachCommand)}
85+
/>
86+
</Popover>
87+
</div>
88+
</div>
89+
90+
<ExpandableSection headerText="No CLI installed?">
91+
<SpaceBetween size="s">
92+
<Box />
93+
<Box>To use dstack, install the CLI on your local machine.</Box>
94+
95+
<Tabs
96+
variant="container"
97+
tabs={[
98+
{
99+
label: 'uv',
100+
id: 'uv',
101+
content: (
102+
<>
103+
<div className={styles.codeWrapper}>
104+
<Code className={styles.code}>{UvInstallCommand}</Code>
105+
106+
<div className={styles.copy}>
107+
<Popover
108+
dismissButton={false}
109+
position="top"
110+
size="small"
111+
triggerType="custom"
112+
content={
113+
<StatusIndicator type="success">
114+
{t('common.copied')}
115+
</StatusIndicator>
116+
}
117+
>
118+
<Button
119+
formAction="none"
120+
iconName="copy"
121+
variant="normal"
122+
onClick={() =>
123+
copyToClipboard(UvInstallCommand)
124+
}
125+
/>
126+
</Popover>
127+
</div>
128+
</div>
129+
</>
130+
),
131+
},
132+
{
133+
label: 'pip',
134+
id: 'pip',
135+
content: (
136+
<>
137+
<div className={styles.codeWrapper}>
138+
<Code className={styles.code}>{PipInstallCommand}</Code>
139+
140+
<div className={styles.copy}>
141+
<Popover
142+
dismissButton={false}
143+
position="top"
144+
size="small"
145+
triggerType="custom"
146+
content={
147+
<StatusIndicator type="success">
148+
{t('common.copied')}
149+
</StatusIndicator>
150+
}
151+
>
152+
<Button
153+
formAction="none"
154+
iconName="copy"
155+
variant="normal"
156+
onClick={() =>
157+
copyToClipboard(PipInstallCommand)
158+
}
159+
/>
160+
</Popover>
161+
</div>
162+
</div>
163+
</>
164+
),
165+
},
166+
]}
167+
/>
168+
169+
<Box>And then configure the project.</Box>
170+
171+
<div className={styles.codeWrapper}>
172+
<Code className={styles.code}>{configCliCommand}</Code>
173+
174+
<div className={styles.copy}>
175+
<Popover
176+
dismissButton={false}
177+
position="top"
178+
size="small"
179+
triggerType="custom"
180+
content={
181+
<StatusIndicator type="success">
182+
{t('common.copied')}
183+
</StatusIndicator>
184+
}
185+
>
186+
<Button
187+
formAction="none"
188+
iconName="copy"
189+
variant="normal"
190+
onClick={copyCliCommand}
191+
/>
192+
</Popover>
193+
</div>
194+
</div>
195+
</SpaceBetween>
196+
</ExpandableSection>
197+
</SpaceBetween>
198+
),
199+
isOptional: true,
200+
},
201+
{
202+
title: 'Open',
203+
description: 'After the CLI is attached, you can open the forwarded ports.',
204+
content: (
205+
<SpaceBetween size="s">
206+
{appSpecs.length === 1 ? (
207+
<Button
208+
variant="primary"
209+
external={true}
210+
onClick={() => openPort(getPort(appSpecs[0]))}
211+
>
212+
Open port
213+
</Button>
214+
) : (
215+
<ButtonDropdown
216+
variant="primary"
217+
mainAction={{
218+
text: `Open port ${selectedPort}`,
219+
external: true,
220+
onClick: () => openPort(selectedPort),
221+
}}
222+
items={appSpecs.map((spec) => {
223+
const port = getPort(spec);
224+
225+
return {
226+
id: String(port),
227+
text: `Port ${port}`,
228+
external: true,
229+
};
230+
})}
231+
onItemClick={({ detail }) => {
232+
const port = Number(detail.id);
233+
setSelectedPort(port);
234+
openPort(port);
235+
}}
236+
/>
237+
)}
238+
</SpaceBetween>
239+
),
240+
isOptional: true,
241+
},
242+
]}
243+
/>
244+
)}
245+
246+
{run.status !== 'running' && (
247+
<SpaceBetween size="s">
248+
<Box />
249+
<Alert type="info">Waiting for the run to start.</Alert>
250+
</SpaceBetween>
251+
)}
252+
</Container>
253+
);
254+
};

frontend/src/pages/Runs/Details/RunDetails/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { EventsList } from '../Events/List';
3636
import { JobList } from '../Jobs/List';
3737
import { ConnectToRunWithDevEnvConfiguration } from './ConnectToRunWithDevEnvConfiguration';
3838
import { ConnectToServiceRun } from './ConnectToServiceRun';
39+
import { ConnectToTaskRun } from './ConnectToTaskRun';
3940

4041
export const RunDetails = () => {
4142
const { t } = useTranslation();
@@ -102,7 +103,7 @@ export const RunDetails = () => {
102103

103104
<div>
104105
<Box variant="awsui-key-label">{t('projects.run.configuration')}</Box>
105-
<div>{runData.run_spec.configuration_path}</div>
106+
<div>{runData.run_spec.configuration_path || '-'}</div>
106107
</div>
107108

108109
<div>
@@ -211,6 +212,11 @@ export const RunDetails = () => {
211212
<ConnectToServiceRun run={runData} />
212213
)}
213214

215+
{runData.run_spec.configuration.type === 'task' && !runIsStopped(runData.status) &&
216+
(runData.jobs[0]?.job_spec?.app_specs?.length ?? 0) > 0 && (
217+
<ConnectToTaskRun run={runData} />
218+
)}
219+
214220
{runData.jobs.length > 1 && (
215221
<JobList
216222
projectName={paramProjectName}

frontend/src/pages/Runs/Launch/components/ParamsWizardStep/index.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,6 @@ export const ParamsWizardStep: React.FC<ParamsWizardStepProps> = ({ formMethods,
5454
if (detail.activeTabId === DockerPythonTabs.PYTHON) {
5555
setValue(FORM_FIELD_NAMES.image, '');
5656
}
57-
58-
setValue(FORM_FIELD_NAMES.docker, detail.activeTabId === DockerPythonTabs.DOCKER);
5957
};
6058

6159
const defaultPassword = generateSecurePassword(20);

frontend/src/pages/Runs/Launch/hooks/useGenerateYaml.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ export const useGenerateYaml = ({ formValues, configuration, envParam, backends
3232

3333
...(name ? { name } : {}),
3434
...(ide ? { ide } : {}),
35-
...(docker ? { docker } : {}),
3635
...(image ? { image } : {}),
3736
...(python ? { python } : {}),
3837
...(envEntries.length > 0 ? { env: envEntries } : {}),

frontend/src/pages/Runs/Launch/hooks/useGetRunSpecFromYaml.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const supportedFields: (keyof TDevEnvironmentConfiguration | keyof TServiceConfi
3636
'repos',
3737
'auth',
3838
'commands',
39+
'ports',
3940
'port',
4041
'gateway',
4142
'https',

frontend/src/types/run.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ declare interface IJobProbe {
241241
}
242242

243243
declare interface IJobSpec {
244-
app_specs?: IAppSpec;
244+
app_specs?: IAppSpec[];
245245
commands: string[];
246246
env?: { [key: string]: string };
247247
home_dir?: string;

0 commit comments

Comments
 (0)