Skip to content

Commit d48b15f

Browse files
[Feature] Allow to see JSON state of runs/volumes/fleets/gateways via CLI/UI (#3445)
* [Feature] Added `dstack inspect` CLI command * [Feature] Add `Inspect` tab to the run and fleet pages * PR review feedback. Replaced `dstack inspect run|fleet|volume|gateway` with seprate commands `datack run|fleet|volume|gateway list --json`. * PR review: `dstack run|fleet get --json` Updated how UUID format errors are handled * PR review: `dstack run|fleet get --json` Better handling edge case (empty ID)
1 parent 2a4c0e1 commit d48b15f

File tree

15 files changed

+447
-7
lines changed

15 files changed

+447
-7
lines changed

frontend/src/locale/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@
396396
"log": "Logs",
397397
"log_empty_message_title": "No logs",
398398
"log_empty_message_text": "No logs to display.",
399+
"inspect": "Inspect",
399400
"run_name": "Name",
400401
"workflow_name": "Workflow",
401402
"configuration": "Configuration",
@@ -573,6 +574,7 @@
573574
"fleet_placeholder": "Filtering by fleet",
574575
"fleet_name": "Fleet name",
575576
"total_instances": "Number of instances",
577+
"inspect": "Inspect",
576578
"empty_message_title": "No fleets",
577579
"empty_message_text": "No fleets to display.",
578580
"nomatch_message_title": "No matches",
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React, { useEffect, useMemo, useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { useParams } from 'react-router-dom';
4+
import ace from 'ace-builds';
5+
import CodeEditor, { CodeEditorProps } from '@cloudscape-design/components/code-editor';
6+
import { Mode } from '@cloudscape-design/global-styles';
7+
8+
import { Container, Header, Loader } from 'components';
9+
import { CODE_EDITOR_I18N_STRINGS } from 'components/form/CodeEditor/constants';
10+
11+
import { useAppSelector } from 'hooks';
12+
import { useGetFleetDetailsQuery } from 'services/fleet';
13+
14+
import { selectSystemMode } from 'App/slice';
15+
16+
import 'ace-builds/src-noconflict/theme-cloud_editor';
17+
import 'ace-builds/src-noconflict/theme-cloud_editor_dark';
18+
import 'ace-builds/src-noconflict/mode-json';
19+
import 'ace-builds/src-noconflict/ext-language_tools';
20+
21+
ace.config.set('useWorker', false);
22+
23+
interface AceEditorElement extends HTMLElement {
24+
env?: {
25+
editor?: {
26+
setReadOnly: (readOnly: boolean) => void;
27+
};
28+
};
29+
}
30+
31+
export const FleetInspect = () => {
32+
const { t } = useTranslation();
33+
const params = useParams();
34+
const paramProjectName = params.projectName ?? '';
35+
const paramFleetId = params.fleetId ?? '';
36+
37+
const systemMode = useAppSelector(selectSystemMode) ?? '';
38+
39+
const { data: fleetData, isLoading } = useGetFleetDetailsQuery(
40+
{
41+
projectName: paramProjectName,
42+
fleetId: paramFleetId,
43+
},
44+
{
45+
refetchOnMountOrArgChange: true,
46+
},
47+
);
48+
49+
const [codeEditorPreferences, setCodeEditorPreferences] = useState<CodeEditorProps['preferences']>(() => ({
50+
theme: systemMode === Mode.Dark ? 'cloud_editor_dark' : 'cloud_editor',
51+
}));
52+
53+
useEffect(() => {
54+
if (systemMode === Mode.Dark)
55+
setCodeEditorPreferences({
56+
theme: 'cloud_editor_dark',
57+
});
58+
else
59+
setCodeEditorPreferences({
60+
theme: 'cloud_editor',
61+
});
62+
}, [systemMode]);
63+
64+
const onCodeEditorPreferencesChange: CodeEditorProps['onPreferencesChange'] = (e) => {
65+
setCodeEditorPreferences(e.detail);
66+
};
67+
68+
const jsonContent = useMemo(() => {
69+
if (!fleetData) return '';
70+
return JSON.stringify(fleetData, null, 2);
71+
}, [fleetData]);
72+
73+
// Set editor to read-only after it loads
74+
useEffect(() => {
75+
const timer = setTimeout(() => {
76+
// Find the ace editor instance in the DOM
77+
const editorElements = document.querySelectorAll('.ace_editor');
78+
editorElements.forEach((element: Element) => {
79+
const aceEditor = (element as AceEditorElement).env?.editor;
80+
if (aceEditor) {
81+
aceEditor.setReadOnly(true);
82+
}
83+
});
84+
}, 100);
85+
86+
return () => clearTimeout(timer);
87+
}, [jsonContent]);
88+
89+
if (isLoading)
90+
return (
91+
<Container>
92+
<Loader />
93+
</Container>
94+
);
95+
96+
return (
97+
<Container header={<Header variant="h2">{t('fleets.inspect')}</Header>}>
98+
<CodeEditor
99+
value={jsonContent}
100+
language="json"
101+
i18nStrings={CODE_EDITOR_I18N_STRINGS}
102+
ace={ace}
103+
themes={{ light: [], dark: [] }}
104+
preferences={codeEditorPreferences}
105+
onPreferencesChange={onCodeEditorPreferencesChange}
106+
editorContentHeight={600}
107+
onChange={() => {
108+
// Prevent editing - onChange is required but we ignore changes
109+
}}
110+
/>
111+
</Container>
112+
);
113+
};

frontend/src/pages/Fleets/Details/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button, ContentLayout, DetailsHeader, Tabs } from 'components';
77
enum CodeTab {
88
Details = 'details',
99
Events = 'events',
10+
Inspect = 'inspect',
1011
}
1112

1213
import { useBreadcrumbs } from 'hooks';
@@ -96,6 +97,11 @@ export const FleetDetails: React.FC = () => {
9697
id: CodeTab.Events,
9798
href: ROUTES.FLEETS.DETAILS.EVENTS.FORMAT(paramProjectName, paramFleetId),
9899
},
100+
{
101+
label: 'Inspect',
102+
id: CodeTab.Inspect,
103+
href: ROUTES.FLEETS.DETAILS.INSPECT.FORMAT(paramProjectName, paramFleetId),
104+
},
99105
]}
100106
/>
101107

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import React, { useEffect, useMemo, useState } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { useParams } from 'react-router-dom';
4+
import ace from 'ace-builds';
5+
import CodeEditor, { CodeEditorProps } from '@cloudscape-design/components/code-editor';
6+
import { Mode } from '@cloudscape-design/global-styles';
7+
8+
import { Container, Header, Loader } from 'components';
9+
import { CODE_EDITOR_I18N_STRINGS } from 'components/form/CodeEditor/constants';
10+
11+
import { useAppSelector } from 'hooks';
12+
import { useGetRunQuery } from 'services/run';
13+
14+
import { selectSystemMode } from 'App/slice';
15+
16+
import 'ace-builds/src-noconflict/theme-cloud_editor';
17+
import 'ace-builds/src-noconflict/theme-cloud_editor_dark';
18+
import 'ace-builds/src-noconflict/mode-json';
19+
import 'ace-builds/src-noconflict/ext-language_tools';
20+
21+
ace.config.set('useWorker', false);
22+
23+
interface AceEditorElement extends HTMLElement {
24+
env?: {
25+
editor?: {
26+
setReadOnly: (readOnly: boolean) => void;
27+
};
28+
};
29+
}
30+
31+
export const RunInspect = () => {
32+
const { t } = useTranslation();
33+
const params = useParams();
34+
const paramProjectName = params.projectName ?? '';
35+
const paramRunId = params.runId ?? '';
36+
37+
const systemMode = useAppSelector(selectSystemMode) ?? '';
38+
39+
const { data: runData, isLoading } = useGetRunQuery({
40+
project_name: paramProjectName,
41+
id: paramRunId,
42+
});
43+
44+
const [codeEditorPreferences, setCodeEditorPreferences] = useState<CodeEditorProps['preferences']>(() => ({
45+
theme: systemMode === Mode.Dark ? 'cloud_editor_dark' : 'cloud_editor',
46+
}));
47+
48+
useEffect(() => {
49+
if (systemMode === Mode.Dark)
50+
setCodeEditorPreferences({
51+
theme: 'cloud_editor_dark',
52+
});
53+
else
54+
setCodeEditorPreferences({
55+
theme: 'cloud_editor',
56+
});
57+
}, [systemMode]);
58+
59+
const onCodeEditorPreferencesChange: CodeEditorProps['onPreferencesChange'] = (e) => {
60+
setCodeEditorPreferences(e.detail);
61+
};
62+
63+
const jsonContent = useMemo(() => {
64+
if (!runData) return '';
65+
return JSON.stringify(runData, null, 2);
66+
}, [runData]);
67+
68+
// Set editor to read-only after it loads
69+
useEffect(() => {
70+
const timer = setTimeout(() => {
71+
// Find the ace editor instance in the DOM
72+
const editorElements = document.querySelectorAll('.ace_editor');
73+
editorElements.forEach((element: Element) => {
74+
const aceEditor = (element as AceEditorElement).env?.editor;
75+
if (aceEditor) {
76+
aceEditor.setReadOnly(true);
77+
}
78+
});
79+
}, 100);
80+
81+
return () => clearTimeout(timer);
82+
}, [jsonContent]);
83+
84+
if (isLoading)
85+
return (
86+
<Container>
87+
<Loader />
88+
</Container>
89+
);
90+
91+
return (
92+
<Container header={<Header variant="h2">{t('projects.run.inspect')}</Header>}>
93+
<CodeEditor
94+
value={jsonContent}
95+
language="json"
96+
i18nStrings={CODE_EDITOR_I18N_STRINGS}
97+
ace={ace}
98+
themes={{ light: [], dark: [] }}
99+
preferences={codeEditorPreferences}
100+
onPreferencesChange={onCodeEditorPreferencesChange}
101+
editorContentHeight={600}
102+
onChange={() => {
103+
// Prevent editing - onChange is required but we ignore changes
104+
}}
105+
/>
106+
</Container>
107+
);
108+
};

frontend/src/pages/Runs/Details/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export enum CodeTab {
33
Metrics = 'metrics',
44
Logs = 'logs',
55
Events = 'events',
6+
Inspect = 'inspect',
67
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ export const RunDetailsPage: React.FC = () => {
189189
id: CodeTab.Events,
190190
href: ROUTES.PROJECT.DETAILS.RUNS.DETAILS.EVENTS.FORMAT(paramProjectName, paramRunId),
191191
},
192+
{
193+
label: 'Inspect',
194+
id: CodeTab.Inspect,
195+
href: ROUTES.PROJECT.DETAILS.RUNS.DETAILS.INSPECT.FORMAT(paramProjectName, paramRunId),
196+
},
192197
]}
193198
/>
194199
)}

frontend/src/router.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Logout } from 'App/Logout';
1313
import { FleetDetails, FleetList } from 'pages/Fleets';
1414
import { EventsList as FleetEventsList } from 'pages/Fleets/Details/Events';
1515
import { FleetDetails as FleetDetailsGeneral } from 'pages/Fleets/Details/FleetDetails';
16+
import { FleetInspect } from 'pages/Fleets/Details/Inspect';
1617
import { InstanceList } from 'pages/Instances';
1718
import { ModelsList } from 'pages/Models';
1819
import { ModelDetails } from 'pages/Models/Details';
@@ -28,6 +29,7 @@ import {
2829
RunDetailsPage,
2930
RunList,
3031
} from 'pages/Runs';
32+
import { RunInspect } from 'pages/Runs/Details/Inspect';
3133
import { JobDetailsPage } from 'pages/Runs/Details/Jobs/Details';
3234
import { EventsList as JobEvents } from 'pages/Runs/Details/Jobs/Events';
3335
import { CreditsHistoryAdd, UserAdd, UserDetails, UserEdit, UserList } from 'pages/User';
@@ -122,6 +124,10 @@ export const router = createBrowserRouter([
122124
path: ROUTES.PROJECT.DETAILS.RUNS.DETAILS.EVENTS.TEMPLATE,
123125
element: <RunEvents />,
124126
},
127+
{
128+
path: ROUTES.PROJECT.DETAILS.RUNS.DETAILS.INSPECT.TEMPLATE,
129+
element: <RunInspect />,
130+
},
125131
],
126132
},
127133
{
@@ -208,6 +214,10 @@ export const router = createBrowserRouter([
208214
path: ROUTES.FLEETS.DETAILS.EVENTS.TEMPLATE,
209215
element: <FleetEventsList />,
210216
},
217+
{
218+
path: ROUTES.FLEETS.DETAILS.INSPECT.TEMPLATE,
219+
element: <FleetInspect />,
220+
},
211221
],
212222
},
213223

frontend/src/routes.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export const ROUTES = {
4343
FORMAT: (projectName: string, runId: string) =>
4444
buildRoute(ROUTES.PROJECT.DETAILS.RUNS.DETAILS.LOGS.TEMPLATE, { projectName, runId }),
4545
},
46+
INSPECT: {
47+
TEMPLATE: `/projects/:projectName/runs/:runId/inspect`,
48+
FORMAT: (projectName: string, runId: string) =>
49+
buildRoute(ROUTES.PROJECT.DETAILS.RUNS.DETAILS.INSPECT.TEMPLATE, { projectName, runId }),
50+
},
4651
JOBS: {
4752
DETAILS: {
4853
TEMPLATE: `/projects/:projectName/runs/:runId/jobs/:jobName`,
@@ -141,6 +146,11 @@ export const ROUTES = {
141146
FORMAT: (projectName: string, fleetId: string) =>
142147
buildRoute(ROUTES.FLEETS.DETAILS.EVENTS.TEMPLATE, { projectName, fleetId }),
143148
},
149+
INSPECT: {
150+
TEMPLATE: `/projects/:projectName/fleets/:fleetId/inspect`,
151+
FORMAT: (projectName: string, fleetId: string) =>
152+
buildRoute(ROUTES.FLEETS.DETAILS.INSPECT.TEMPLATE, { projectName, fleetId }),
153+
},
144154
},
145155
},
146156

0 commit comments

Comments
 (0)