From e37fc52a5a55d81158dc110c43ac9a3114fd410e Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Sun, 4 Jan 2026 21:19:39 +0100 Subject: [PATCH 1/5] [Feature] Added `dstack inspect` CLI command --- src/dstack/_internal/cli/commands/inspect.py | 237 +++++++++++++++++++ src/dstack/_internal/cli/main.py | 2 + src/dstack/api/server/_fleets.py | 13 +- src/dstack/api/server/_runs.py | 10 +- 4 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 src/dstack/_internal/cli/commands/inspect.py diff --git a/src/dstack/_internal/cli/commands/inspect.py b/src/dstack/_internal/cli/commands/inspect.py new file mode 100644 index 000000000..f86755c07 --- /dev/null +++ b/src/dstack/_internal/cli/commands/inspect.py @@ -0,0 +1,237 @@ +import argparse +from typing import Union +from uuid import UUID + +from dstack._internal.cli.commands import APIBaseCommand +from dstack._internal.cli.services.completion import ( + FleetNameCompleter, + GatewayNameCompleter, + RunNameCompleter, + VolumeNameCompleter, +) +from dstack._internal.cli.utils.common import console +from dstack._internal.core.errors import CLIError, ResourceNotExistsError +from dstack._internal.core.models.fleets import Fleet +from dstack._internal.core.models.gateways import Gateway +from dstack._internal.core.models.runs import Run +from dstack._internal.core.models.volumes import Volume +from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent + + +class InspectCommand(APIBaseCommand): + NAME = "inspect" + DESCRIPTION = "Inspect objects (runs, fleets, volumes, gateways)" + ACCEPT_EXTRA_ARGS = True + + def _normalize_default_subcommand(self) -> None: + """ + Normalize inspect command args to support default 'run' subcommand. + + This allows users to run: + - `dstack inspect NAME` instead of `dstack inspect run NAME` + - `dstack inspect --id UUID` instead of `dstack inspect run --id UUID` + + Since argparse subparsers don't support optional subcommands natively, + we need to manipulate sys.argv before parsing to insert "run" as the + default subcommand when the first argument is not a valid subcommand. + """ + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "inspect" and len(sys.argv) > 2: + arg = sys.argv[2] + valid_subcommands = {"run", "fleet", "volume", "gateway"} + if arg not in valid_subcommands and not arg.startswith("-"): + sys.argv.insert(2, "run") + elif arg == "--id": + sys.argv.insert(2, "run") + + def _register(self) -> None: + self._normalize_default_subcommand() + super()._register() + subparsers = self._parser.add_subparsers( + dest="subcommand", help="Object type to inspect", metavar="TYPE" + ) + + run_parser = subparsers.add_parser( + "run", help="Inspect a run", formatter_class=self._parser.formatter_class + ) + run_parser.add_argument( + "name", + nargs="?", + help="The name of the run", + ).completer = RunNameCompleter() # type: ignore[attr-defined] + run_parser.add_argument( + "--id", + type=str, + help="The ID of the run (UUID)", + ) + run_parser.set_defaults(subfunc=self._inspect_run) + + fleet_parser = subparsers.add_parser( + "fleet", help="Inspect a fleet", formatter_class=self._parser.formatter_class + ) + fleet_parser.add_argument( + "name", + nargs="?", + help="The name of the fleet", + ).completer = FleetNameCompleter() # type: ignore[attr-defined] + fleet_parser.add_argument( + "--id", + type=str, + help="The ID of the fleet (UUID)", + ) + fleet_parser.set_defaults(subfunc=self._inspect_fleet) + + volume_parser = subparsers.add_parser( + "volume", help="Inspect a volume", formatter_class=self._parser.formatter_class + ) + volume_parser.add_argument( + "name", + nargs="?", + help="The name of the volume", + ).completer = VolumeNameCompleter() # type: ignore[attr-defined] + volume_parser.set_defaults(subfunc=self._inspect_volume) + + gateway_parser = subparsers.add_parser( + "gateway", help="Inspect a gateway", formatter_class=self._parser.formatter_class + ) + gateway_parser.add_argument( + "name", + nargs="?", + help="The name of the gateway", + ).completer = GatewayNameCompleter() # type: ignore[attr-defined] + gateway_parser.set_defaults(subfunc=self._inspect_gateway) + + def _command(self, args: argparse.Namespace) -> None: + super()._command(args) + valid_subcommands = {"run", "fleet", "volume", "gateway"} + + if not hasattr(args, "subcommand") or args.subcommand is None: + if args.extra_args: + first_arg = args.extra_args[0] + if first_arg in valid_subcommands: + args.subcommand = first_arg + remaining_args = args.extra_args[1:] + elif first_arg == "--id": + args.subcommand = "run" + remaining_args = args.extra_args + else: + args.subcommand = "run" + remaining_args = args.extra_args + else: + args.subcommand = "run" + remaining_args = [] + + if args.subcommand == "run": + run_parser = argparse.ArgumentParser() + run_parser.add_argument("name", nargs="?") + run_parser.add_argument("--id", type=str) + run_args, _ = run_parser.parse_known_args(remaining_args) + args.name = run_args.name + args.id = run_args.id + args.subfunc = self._inspect_run + elif args.subcommand == "fleet": + fleet_parser = argparse.ArgumentParser() + fleet_parser.add_argument("name", nargs="?") + fleet_parser.add_argument("--id", type=str) + fleet_args, _ = fleet_parser.parse_known_args(remaining_args) + args.name = fleet_args.name + args.id = fleet_args.id + args.subfunc = self._inspect_fleet + elif args.subcommand == "volume": + volume_parser = argparse.ArgumentParser() + volume_parser.add_argument("name", nargs="?") + volume_args, _ = volume_parser.parse_known_args(remaining_args) + args.name = volume_args.name + args.subfunc = self._inspect_volume + elif args.subcommand == "gateway": + gateway_parser = argparse.ArgumentParser() + gateway_parser.add_argument("name", nargs="?") + gateway_args, _ = gateway_parser.parse_known_args(remaining_args) + args.name = gateway_args.name + args.subfunc = self._inspect_gateway + else: + if not hasattr(args, "subfunc") or args.subfunc is None: + if args.subcommand == "run": + args.subfunc = self._inspect_run + elif args.subcommand == "fleet": + args.subfunc = self._inspect_fleet + elif args.subcommand == "volume": + args.subfunc = self._inspect_volume + elif args.subcommand == "gateway": + args.subfunc = self._inspect_gateway + + if not hasattr(args, "subfunc") or args.subfunc is None: + args.subfunc = self._inspect_run + args.subfunc(args) + + def _inspect_run(self, args: argparse.Namespace) -> None: + if not args.name and not args.id: + raise CLIError("Either name or --id must be provided") + + if args.name and args.id: + raise CLIError("Cannot specify both name and --id") + + try: + if args.id: + run_id = UUID(args.id) + run = self.api.client.runs.get(project_name=self.api.project, run_id=run_id) + else: + run = self.api.client.runs.get(self.api.project, args.name) + except ResourceNotExistsError: + console.print(f"Run [code]{args.name or args.id}[/] not found") + exit(1) + except ValueError: + raise CLIError(f"Invalid UUID format: {args.id}") + + self._print_json(run) + + def _inspect_fleet(self, args: argparse.Namespace) -> None: + if not args.name and not args.id: + raise CLIError("Either name or --id must be provided") + + if args.name and args.id: + raise CLIError("Cannot specify both name and --id") + + try: + if args.id: + fleet_id = UUID(args.id) + fleet = self.api.client.fleets.get(self.api.project, fleet_id=fleet_id) + else: + fleet = self.api.client.fleets.get(self.api.project, args.name) + except ResourceNotExistsError: + console.print(f"Fleet [code]{args.name or args.id}[/] not found") + exit(1) + except ValueError: + raise CLIError(f"Invalid UUID format: {args.id}") + + self._print_json(fleet) + + def _inspect_volume(self, args: argparse.Namespace) -> None: + if not args.name: + raise CLIError("Name must be provided") + + try: + volume = self.api.client.volumes.get(project_name=self.api.project, name=args.name) + except ResourceNotExistsError: + console.print("Volume not found") + exit(1) + + self._print_json(volume) + + def _inspect_gateway(self, args: argparse.Namespace) -> None: + if not args.name: + raise CLIError("Name must be provided") + + try: + gateway = self.api.client.gateways.get( + project_name=self.api.project, gateway_name=args.name + ) + except ResourceNotExistsError: + console.print("Gateway not found") + exit(1) + + self._print_json(gateway) + + def _print_json(self, obj: Union[Run, Fleet, Volume, Gateway]) -> None: + print(pydantic_orjson_dumps_with_indent(obj.dict(), default=None)) diff --git a/src/dstack/_internal/cli/main.py b/src/dstack/_internal/cli/main.py index 61f3967ab..8fe931185 100644 --- a/src/dstack/_internal/cli/main.py +++ b/src/dstack/_internal/cli/main.py @@ -12,6 +12,7 @@ from dstack._internal.cli.commands.fleet import FleetCommand from dstack._internal.cli.commands.gateway import GatewayCommand from dstack._internal.cli.commands.init import InitCommand +from dstack._internal.cli.commands.inspect import InspectCommand from dstack._internal.cli.commands.login import LoginCommand from dstack._internal.cli.commands.logs import LogsCommand from dstack._internal.cli.commands.metrics import MetricsCommand @@ -68,6 +69,7 @@ def main(): FleetCommand.register(subparsers) GatewayCommand.register(subparsers) InitCommand.register(subparsers) + InspectCommand.register(subparsers) OfferCommand.register(subparsers) LoginCommand.register(subparsers) LogsCommand.register(subparsers) diff --git a/src/dstack/api/server/_fleets.py b/src/dstack/api/server/_fleets.py index 8f6ea7fcf..9bfb1cb42 100644 --- a/src/dstack/api/server/_fleets.py +++ b/src/dstack/api/server/_fleets.py @@ -1,4 +1,5 @@ -from typing import List, Union +from typing import List, Optional, Union +from uuid import UUID from pydantic import parse_obj_as @@ -24,8 +25,14 @@ def list(self, project_name: str) -> List[Fleet]: resp = self._request(f"/api/project/{project_name}/fleets/list") return parse_obj_as(List[Fleet.__response__], resp.json()) - def get(self, project_name: str, name: str) -> Fleet: - body = GetFleetRequest(name=name) + def get( + self, project_name: str, name: Optional[str] = None, fleet_id: Optional[UUID] = None + ) -> Fleet: + if name is None and fleet_id is None: + raise ValueError("Either name or fleet_id must be provided") + if name is not None and fleet_id is not None: + raise ValueError("Cannot specify both name and fleet_id") + body = GetFleetRequest(name=name, id=fleet_id) resp = self._request( f"/api/project/{project_name}/fleets/get", body=body.json(), diff --git a/src/dstack/api/server/_runs.py b/src/dstack/api/server/_runs.py index 745ce9c78..ead179763 100644 --- a/src/dstack/api/server/_runs.py +++ b/src/dstack/api/server/_runs.py @@ -57,8 +57,14 @@ def list( ) return parse_obj_as(List[Run.__response__], resp.json()) - def get(self, project_name: str, run_name: str) -> Run: - body = GetRunRequest(run_name=run_name) + def get( + self, project_name: str, run_name: Optional[str] = None, run_id: Optional[UUID] = None + ) -> Run: + if run_name is None and run_id is None: + raise ValueError("Either run_name or run_id must be provided") + if run_name is not None and run_id is not None: + raise ValueError("Cannot specify both run_name and run_id") + body = GetRunRequest(run_name=run_name, id=run_id) json_body = body.json() resp = self._request(f"/api/project/{project_name}/runs/get", body=json_body) return parse_obj_as(Run.__response__, resp.json()) From 93297226108966878e4cdda4cfbf0512679d8424 Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Sun, 4 Jan 2026 22:05:52 +0100 Subject: [PATCH 2/5] [Feature] Add `Inspect` tab to the run and fleet pages --- frontend/src/locale/en.json | 2 + .../pages/Fleets/Details/Inspect/index.tsx | 113 ++++++++++++++++++ frontend/src/pages/Fleets/Details/index.tsx | 6 + .../src/pages/Runs/Details/Inspect/index.tsx | 108 +++++++++++++++++ frontend/src/pages/Runs/Details/constants.ts | 1 + frontend/src/pages/Runs/Details/index.tsx | 5 + frontend/src/router.tsx | 10 ++ frontend/src/routes.ts | 10 ++ 8 files changed, 255 insertions(+) create mode 100644 frontend/src/pages/Fleets/Details/Inspect/index.tsx create mode 100644 frontend/src/pages/Runs/Details/Inspect/index.tsx diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index f02615108..da3fe00fa 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -396,6 +396,7 @@ "log": "Logs", "log_empty_message_title": "No logs", "log_empty_message_text": "No logs to display.", + "inspect": "Inspect", "run_name": "Name", "workflow_name": "Workflow", "configuration": "Configuration", @@ -573,6 +574,7 @@ "fleet_placeholder": "Filtering by fleet", "fleet_name": "Fleet name", "total_instances": "Number of instances", + "inspect": "Inspect", "empty_message_title": "No fleets", "empty_message_text": "No fleets to display.", "nomatch_message_title": "No matches", diff --git a/frontend/src/pages/Fleets/Details/Inspect/index.tsx b/frontend/src/pages/Fleets/Details/Inspect/index.tsx new file mode 100644 index 000000000..844ebe849 --- /dev/null +++ b/frontend/src/pages/Fleets/Details/Inspect/index.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import ace from 'ace-builds'; +import CodeEditor, { CodeEditorProps } from '@cloudscape-design/components/code-editor'; +import { Mode } from '@cloudscape-design/global-styles'; + +import { Container, Header, Loader } from 'components'; +import { CODE_EDITOR_I18N_STRINGS } from 'components/form/CodeEditor/constants'; + +import { useAppSelector } from 'hooks'; +import { useGetFleetDetailsQuery } from 'services/fleet'; + +import { selectSystemMode } from 'App/slice'; + +import 'ace-builds/src-noconflict/theme-cloud_editor'; +import 'ace-builds/src-noconflict/theme-cloud_editor_dark'; +import 'ace-builds/src-noconflict/mode-json'; +import 'ace-builds/src-noconflict/ext-language_tools'; + +ace.config.set('useWorker', false); + +interface AceEditorElement extends HTMLElement { + env?: { + editor?: { + setReadOnly: (readOnly: boolean) => void; + }; + }; +} + +export const FleetInspect = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramProjectName = params.projectName ?? ''; + const paramFleetId = params.fleetId ?? ''; + + const systemMode = useAppSelector(selectSystemMode) ?? ''; + + const { data: fleetData, isLoading } = useGetFleetDetailsQuery( + { + projectName: paramProjectName, + fleetId: paramFleetId, + }, + { + refetchOnMountOrArgChange: true, + }, + ); + + const [codeEditorPreferences, setCodeEditorPreferences] = useState(() => ({ + theme: systemMode === Mode.Dark ? 'cloud_editor_dark' : 'cloud_editor', + })); + + useEffect(() => { + if (systemMode === Mode.Dark) + setCodeEditorPreferences({ + theme: 'cloud_editor_dark', + }); + else + setCodeEditorPreferences({ + theme: 'cloud_editor', + }); + }, [systemMode]); + + const onCodeEditorPreferencesChange: CodeEditorProps['onPreferencesChange'] = (e) => { + setCodeEditorPreferences(e.detail); + }; + + const jsonContent = useMemo(() => { + if (!fleetData) return ''; + return JSON.stringify(fleetData, null, 2); + }, [fleetData]); + + // Set editor to read-only after it loads + useEffect(() => { + const timer = setTimeout(() => { + // Find the ace editor instance in the DOM + const editorElements = document.querySelectorAll('.ace_editor'); + editorElements.forEach((element: Element) => { + const aceEditor = (element as AceEditorElement).env?.editor; + if (aceEditor) { + aceEditor.setReadOnly(true); + } + }); + }, 100); + + return () => clearTimeout(timer); + }, [jsonContent]); + + if (isLoading) + return ( + + + + ); + + return ( + {t('fleets.inspect')}}> + { + // Prevent editing - onChange is required but we ignore changes + }} + /> + + ); +}; diff --git a/frontend/src/pages/Fleets/Details/index.tsx b/frontend/src/pages/Fleets/Details/index.tsx index d3690fcff..6e5d9e6d7 100644 --- a/frontend/src/pages/Fleets/Details/index.tsx +++ b/frontend/src/pages/Fleets/Details/index.tsx @@ -7,6 +7,7 @@ import { Button, ContentLayout, DetailsHeader, Tabs } from 'components'; enum CodeTab { Details = 'details', Events = 'events', + Inspect = 'inspect', } import { useBreadcrumbs } from 'hooks'; @@ -96,6 +97,11 @@ export const FleetDetails: React.FC = () => { id: CodeTab.Events, href: ROUTES.FLEETS.DETAILS.EVENTS.FORMAT(paramProjectName, paramFleetId), }, + { + label: 'Inspect', + id: CodeTab.Inspect, + href: ROUTES.FLEETS.DETAILS.INSPECT.FORMAT(paramProjectName, paramFleetId), + }, ]} /> diff --git a/frontend/src/pages/Runs/Details/Inspect/index.tsx b/frontend/src/pages/Runs/Details/Inspect/index.tsx new file mode 100644 index 000000000..f37aa90ad --- /dev/null +++ b/frontend/src/pages/Runs/Details/Inspect/index.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import ace from 'ace-builds'; +import CodeEditor, { CodeEditorProps } from '@cloudscape-design/components/code-editor'; +import { Mode } from '@cloudscape-design/global-styles'; + +import { Container, Header, Loader } from 'components'; +import { CODE_EDITOR_I18N_STRINGS } from 'components/form/CodeEditor/constants'; + +import { useAppSelector } from 'hooks'; +import { useGetRunQuery } from 'services/run'; + +import { selectSystemMode } from 'App/slice'; + +import 'ace-builds/src-noconflict/theme-cloud_editor'; +import 'ace-builds/src-noconflict/theme-cloud_editor_dark'; +import 'ace-builds/src-noconflict/mode-json'; +import 'ace-builds/src-noconflict/ext-language_tools'; + +ace.config.set('useWorker', false); + +interface AceEditorElement extends HTMLElement { + env?: { + editor?: { + setReadOnly: (readOnly: boolean) => void; + }; + }; +} + +export const RunInspect = () => { + const { t } = useTranslation(); + const params = useParams(); + const paramProjectName = params.projectName ?? ''; + const paramRunId = params.runId ?? ''; + + const systemMode = useAppSelector(selectSystemMode) ?? ''; + + const { data: runData, isLoading } = useGetRunQuery({ + project_name: paramProjectName, + id: paramRunId, + }); + + const [codeEditorPreferences, setCodeEditorPreferences] = useState(() => ({ + theme: systemMode === Mode.Dark ? 'cloud_editor_dark' : 'cloud_editor', + })); + + useEffect(() => { + if (systemMode === Mode.Dark) + setCodeEditorPreferences({ + theme: 'cloud_editor_dark', + }); + else + setCodeEditorPreferences({ + theme: 'cloud_editor', + }); + }, [systemMode]); + + const onCodeEditorPreferencesChange: CodeEditorProps['onPreferencesChange'] = (e) => { + setCodeEditorPreferences(e.detail); + }; + + const jsonContent = useMemo(() => { + if (!runData) return ''; + return JSON.stringify(runData, null, 2); + }, [runData]); + + // Set editor to read-only after it loads + useEffect(() => { + const timer = setTimeout(() => { + // Find the ace editor instance in the DOM + const editorElements = document.querySelectorAll('.ace_editor'); + editorElements.forEach((element: Element) => { + const aceEditor = (element as AceEditorElement).env?.editor; + if (aceEditor) { + aceEditor.setReadOnly(true); + } + }); + }, 100); + + return () => clearTimeout(timer); + }, [jsonContent]); + + if (isLoading) + return ( + + + + ); + + return ( + {t('projects.run.inspect')}}> + { + // Prevent editing - onChange is required but we ignore changes + }} + /> + + ); +}; diff --git a/frontend/src/pages/Runs/Details/constants.ts b/frontend/src/pages/Runs/Details/constants.ts index 1bf4bc69c..7a63d3f95 100644 --- a/frontend/src/pages/Runs/Details/constants.ts +++ b/frontend/src/pages/Runs/Details/constants.ts @@ -3,4 +3,5 @@ export enum CodeTab { Metrics = 'metrics', Logs = 'logs', Events = 'events', + Inspect = 'inspect', } diff --git a/frontend/src/pages/Runs/Details/index.tsx b/frontend/src/pages/Runs/Details/index.tsx index 78e9850c8..5195b4fdc 100644 --- a/frontend/src/pages/Runs/Details/index.tsx +++ b/frontend/src/pages/Runs/Details/index.tsx @@ -189,6 +189,11 @@ export const RunDetailsPage: React.FC = () => { id: CodeTab.Events, href: ROUTES.PROJECT.DETAILS.RUNS.DETAILS.EVENTS.FORMAT(paramProjectName, paramRunId), }, + { + label: 'Inspect', + id: CodeTab.Inspect, + href: ROUTES.PROJECT.DETAILS.RUNS.DETAILS.INSPECT.FORMAT(paramProjectName, paramRunId), + }, ]} /> )} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 1bba4cb16..fbdeca294 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -13,6 +13,7 @@ import { Logout } from 'App/Logout'; import { FleetDetails, FleetList } from 'pages/Fleets'; import { EventsList as FleetEventsList } from 'pages/Fleets/Details/Events'; import { FleetDetails as FleetDetailsGeneral } from 'pages/Fleets/Details/FleetDetails'; +import { FleetInspect } from 'pages/Fleets/Details/Inspect'; import { InstanceList } from 'pages/Instances'; import { ModelsList } from 'pages/Models'; import { ModelDetails } from 'pages/Models/Details'; @@ -28,6 +29,7 @@ import { RunDetailsPage, RunList, } from 'pages/Runs'; +import { RunInspect } from 'pages/Runs/Details/Inspect'; import { JobDetailsPage } from 'pages/Runs/Details/Jobs/Details'; import { EventsList as JobEvents } from 'pages/Runs/Details/Jobs/Events'; import { CreditsHistoryAdd, UserAdd, UserDetails, UserEdit, UserList } from 'pages/User'; @@ -122,6 +124,10 @@ export const router = createBrowserRouter([ path: ROUTES.PROJECT.DETAILS.RUNS.DETAILS.EVENTS.TEMPLATE, element: , }, + { + path: ROUTES.PROJECT.DETAILS.RUNS.DETAILS.INSPECT.TEMPLATE, + element: , + }, ], }, { @@ -208,6 +214,10 @@ export const router = createBrowserRouter([ path: ROUTES.FLEETS.DETAILS.EVENTS.TEMPLATE, element: , }, + { + path: ROUTES.FLEETS.DETAILS.INSPECT.TEMPLATE, + element: , + }, ], }, diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 6bc1fb0e5..fea2f978a 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -43,6 +43,11 @@ export const ROUTES = { FORMAT: (projectName: string, runId: string) => buildRoute(ROUTES.PROJECT.DETAILS.RUNS.DETAILS.LOGS.TEMPLATE, { projectName, runId }), }, + INSPECT: { + TEMPLATE: `/projects/:projectName/runs/:runId/inspect`, + FORMAT: (projectName: string, runId: string) => + buildRoute(ROUTES.PROJECT.DETAILS.RUNS.DETAILS.INSPECT.TEMPLATE, { projectName, runId }), + }, JOBS: { DETAILS: { TEMPLATE: `/projects/:projectName/runs/:runId/jobs/:jobName`, @@ -141,6 +146,11 @@ export const ROUTES = { FORMAT: (projectName: string, fleetId: string) => buildRoute(ROUTES.FLEETS.DETAILS.EVENTS.TEMPLATE, { projectName, fleetId }), }, + INSPECT: { + TEMPLATE: `/projects/:projectName/fleets/:fleetId/inspect`, + FORMAT: (projectName: string, fleetId: string) => + buildRoute(ROUTES.FLEETS.DETAILS.INSPECT.TEMPLATE, { projectName, fleetId }), + }, }, }, From bcb752f9580e5da7c706e74d7f0ca314dfcda5bb Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Wed, 7 Jan 2026 17:50:31 +0100 Subject: [PATCH 3/5] PR review feedback. Replaced `dstack inspect run|fleet|volume|gateway` with seprate commands `datack run|fleet|volume|gateway list --json`. --- src/dstack/_internal/cli/commands/fleet.py | 45 +++- src/dstack/_internal/cli/commands/gateway.py | 29 ++- src/dstack/_internal/cli/commands/inspect.py | 237 ------------------- src/dstack/_internal/cli/commands/run.py | 65 +++++ src/dstack/_internal/cli/commands/volume.py | 27 +++ src/dstack/_internal/cli/main.py | 4 +- 6 files changed, 166 insertions(+), 241 deletions(-) delete mode 100644 src/dstack/_internal/cli/commands/inspect.py create mode 100644 src/dstack/_internal/cli/commands/run.py diff --git a/src/dstack/_internal/cli/commands/fleet.py b/src/dstack/_internal/cli/commands/fleet.py index c6a11abc3..f811639c0 100644 --- a/src/dstack/_internal/cli/commands/fleet.py +++ b/src/dstack/_internal/cli/commands/fleet.py @@ -1,5 +1,6 @@ import argparse import time +from uuid import UUID from rich.live import Live @@ -12,7 +13,8 @@ console, ) from dstack._internal.cli.utils.fleet import get_fleets_table, print_fleets_table -from dstack._internal.core.errors import ResourceNotExistsError +from dstack._internal.core.errors import CLIError, ResourceNotExistsError +from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent class FleetCommand(APIBaseCommand): @@ -63,6 +65,29 @@ def _register(self): ) delete_parser.set_defaults(subfunc=self._delete) + get_parser = subparsers.add_parser( + "get", help="Get a fleet", formatter_class=self._parser.formatter_class + ) + name_group = get_parser.add_mutually_exclusive_group(required=True) + name_group.add_argument( + "name", + nargs="?", + metavar="NAME", + help="The name of the fleet", + ).completer = FleetNameCompleter() # type: ignore[attr-defined] + name_group.add_argument( + "--id", + type=str, + help="The ID of the fleet (UUID)", + ) + get_parser.add_argument( + "--json", + action="store_true", + required=True, + help="Output in JSON format", + ) + get_parser.set_defaults(subfunc=self._get) + def _command(self, args: argparse.Namespace): super()._command(args) args.subfunc(args) @@ -112,3 +137,21 @@ def _delete(self, args: argparse.Namespace): ) console.print(f"Fleet [code]{args.name}[/] instances deleted") + + def _get(self, args: argparse.Namespace): + # TODO: Implement non-json output format + try: + if args.id: + fleet_id = UUID(args.id) + fleet = self.api.client.fleets.get( + project_name=self.api.project, fleet_id=fleet_id + ) + else: + fleet = self.api.client.fleets.get(project_name=self.api.project, name=args.name) + except ResourceNotExistsError: + console.print(f"Fleet [code]{args.name or args.id}[/] not found") + exit(1) + except ValueError: + raise CLIError(f"Invalid UUID format: {args.id}") + + print(pydantic_orjson_dumps_with_indent(fleet.dict(), default=None)) diff --git a/src/dstack/_internal/cli/commands/gateway.py b/src/dstack/_internal/cli/commands/gateway.py index 31ecef3dd..be7e6138a 100644 --- a/src/dstack/_internal/cli/commands/gateway.py +++ b/src/dstack/_internal/cli/commands/gateway.py @@ -16,7 +16,8 @@ print_gateways_json, print_gateways_table, ) -from dstack._internal.core.errors import CLIError +from dstack._internal.core.errors import CLIError, ResourceNotExistsError +from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent from dstack._internal.utils.logging import get_logger logger = get_logger(__name__) @@ -83,6 +84,20 @@ def _register(self): ) update_parser.add_argument("--domain", help="Set the domain for the gateway") + get_parser = subparsers.add_parser( + "get", help="Get a gateway", formatter_class=self._parser.formatter_class + ) + get_parser.add_argument( + "name", metavar="NAME", help="The name of the gateway" + ).completer = GatewayNameCompleter() # type: ignore[attr-defined] + get_parser.add_argument( + "--json", + action="store_true", + required=True, + help="Output in JSON format", + ) + get_parser.set_defaults(subfunc=self._get) + def _command(self, args: argparse.Namespace): super()._command(args) # TODO handle errors @@ -130,3 +145,15 @@ def _update(self, args: argparse.Namespace): ) gateway = self.api.client.gateways.get(self.api.project, args.name) print_gateways_table([gateway]) + + def _get(self, args: argparse.Namespace): + # TODO: Implement non-json output format + try: + gateway = self.api.client.gateways.get( + project_name=self.api.project, gateway_name=args.name + ) + except ResourceNotExistsError: + console.print("Gateway not found") + exit(1) + + print(pydantic_orjson_dumps_with_indent(gateway.dict(), default=None)) diff --git a/src/dstack/_internal/cli/commands/inspect.py b/src/dstack/_internal/cli/commands/inspect.py deleted file mode 100644 index f86755c07..000000000 --- a/src/dstack/_internal/cli/commands/inspect.py +++ /dev/null @@ -1,237 +0,0 @@ -import argparse -from typing import Union -from uuid import UUID - -from dstack._internal.cli.commands import APIBaseCommand -from dstack._internal.cli.services.completion import ( - FleetNameCompleter, - GatewayNameCompleter, - RunNameCompleter, - VolumeNameCompleter, -) -from dstack._internal.cli.utils.common import console -from dstack._internal.core.errors import CLIError, ResourceNotExistsError -from dstack._internal.core.models.fleets import Fleet -from dstack._internal.core.models.gateways import Gateway -from dstack._internal.core.models.runs import Run -from dstack._internal.core.models.volumes import Volume -from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent - - -class InspectCommand(APIBaseCommand): - NAME = "inspect" - DESCRIPTION = "Inspect objects (runs, fleets, volumes, gateways)" - ACCEPT_EXTRA_ARGS = True - - def _normalize_default_subcommand(self) -> None: - """ - Normalize inspect command args to support default 'run' subcommand. - - This allows users to run: - - `dstack inspect NAME` instead of `dstack inspect run NAME` - - `dstack inspect --id UUID` instead of `dstack inspect run --id UUID` - - Since argparse subparsers don't support optional subcommands natively, - we need to manipulate sys.argv before parsing to insert "run" as the - default subcommand when the first argument is not a valid subcommand. - """ - import sys - - if len(sys.argv) > 1 and sys.argv[1] == "inspect" and len(sys.argv) > 2: - arg = sys.argv[2] - valid_subcommands = {"run", "fleet", "volume", "gateway"} - if arg not in valid_subcommands and not arg.startswith("-"): - sys.argv.insert(2, "run") - elif arg == "--id": - sys.argv.insert(2, "run") - - def _register(self) -> None: - self._normalize_default_subcommand() - super()._register() - subparsers = self._parser.add_subparsers( - dest="subcommand", help="Object type to inspect", metavar="TYPE" - ) - - run_parser = subparsers.add_parser( - "run", help="Inspect a run", formatter_class=self._parser.formatter_class - ) - run_parser.add_argument( - "name", - nargs="?", - help="The name of the run", - ).completer = RunNameCompleter() # type: ignore[attr-defined] - run_parser.add_argument( - "--id", - type=str, - help="The ID of the run (UUID)", - ) - run_parser.set_defaults(subfunc=self._inspect_run) - - fleet_parser = subparsers.add_parser( - "fleet", help="Inspect a fleet", formatter_class=self._parser.formatter_class - ) - fleet_parser.add_argument( - "name", - nargs="?", - help="The name of the fleet", - ).completer = FleetNameCompleter() # type: ignore[attr-defined] - fleet_parser.add_argument( - "--id", - type=str, - help="The ID of the fleet (UUID)", - ) - fleet_parser.set_defaults(subfunc=self._inspect_fleet) - - volume_parser = subparsers.add_parser( - "volume", help="Inspect a volume", formatter_class=self._parser.formatter_class - ) - volume_parser.add_argument( - "name", - nargs="?", - help="The name of the volume", - ).completer = VolumeNameCompleter() # type: ignore[attr-defined] - volume_parser.set_defaults(subfunc=self._inspect_volume) - - gateway_parser = subparsers.add_parser( - "gateway", help="Inspect a gateway", formatter_class=self._parser.formatter_class - ) - gateway_parser.add_argument( - "name", - nargs="?", - help="The name of the gateway", - ).completer = GatewayNameCompleter() # type: ignore[attr-defined] - gateway_parser.set_defaults(subfunc=self._inspect_gateway) - - def _command(self, args: argparse.Namespace) -> None: - super()._command(args) - valid_subcommands = {"run", "fleet", "volume", "gateway"} - - if not hasattr(args, "subcommand") or args.subcommand is None: - if args.extra_args: - first_arg = args.extra_args[0] - if first_arg in valid_subcommands: - args.subcommand = first_arg - remaining_args = args.extra_args[1:] - elif first_arg == "--id": - args.subcommand = "run" - remaining_args = args.extra_args - else: - args.subcommand = "run" - remaining_args = args.extra_args - else: - args.subcommand = "run" - remaining_args = [] - - if args.subcommand == "run": - run_parser = argparse.ArgumentParser() - run_parser.add_argument("name", nargs="?") - run_parser.add_argument("--id", type=str) - run_args, _ = run_parser.parse_known_args(remaining_args) - args.name = run_args.name - args.id = run_args.id - args.subfunc = self._inspect_run - elif args.subcommand == "fleet": - fleet_parser = argparse.ArgumentParser() - fleet_parser.add_argument("name", nargs="?") - fleet_parser.add_argument("--id", type=str) - fleet_args, _ = fleet_parser.parse_known_args(remaining_args) - args.name = fleet_args.name - args.id = fleet_args.id - args.subfunc = self._inspect_fleet - elif args.subcommand == "volume": - volume_parser = argparse.ArgumentParser() - volume_parser.add_argument("name", nargs="?") - volume_args, _ = volume_parser.parse_known_args(remaining_args) - args.name = volume_args.name - args.subfunc = self._inspect_volume - elif args.subcommand == "gateway": - gateway_parser = argparse.ArgumentParser() - gateway_parser.add_argument("name", nargs="?") - gateway_args, _ = gateway_parser.parse_known_args(remaining_args) - args.name = gateway_args.name - args.subfunc = self._inspect_gateway - else: - if not hasattr(args, "subfunc") or args.subfunc is None: - if args.subcommand == "run": - args.subfunc = self._inspect_run - elif args.subcommand == "fleet": - args.subfunc = self._inspect_fleet - elif args.subcommand == "volume": - args.subfunc = self._inspect_volume - elif args.subcommand == "gateway": - args.subfunc = self._inspect_gateway - - if not hasattr(args, "subfunc") or args.subfunc is None: - args.subfunc = self._inspect_run - args.subfunc(args) - - def _inspect_run(self, args: argparse.Namespace) -> None: - if not args.name and not args.id: - raise CLIError("Either name or --id must be provided") - - if args.name and args.id: - raise CLIError("Cannot specify both name and --id") - - try: - if args.id: - run_id = UUID(args.id) - run = self.api.client.runs.get(project_name=self.api.project, run_id=run_id) - else: - run = self.api.client.runs.get(self.api.project, args.name) - except ResourceNotExistsError: - console.print(f"Run [code]{args.name or args.id}[/] not found") - exit(1) - except ValueError: - raise CLIError(f"Invalid UUID format: {args.id}") - - self._print_json(run) - - def _inspect_fleet(self, args: argparse.Namespace) -> None: - if not args.name and not args.id: - raise CLIError("Either name or --id must be provided") - - if args.name and args.id: - raise CLIError("Cannot specify both name and --id") - - try: - if args.id: - fleet_id = UUID(args.id) - fleet = self.api.client.fleets.get(self.api.project, fleet_id=fleet_id) - else: - fleet = self.api.client.fleets.get(self.api.project, args.name) - except ResourceNotExistsError: - console.print(f"Fleet [code]{args.name or args.id}[/] not found") - exit(1) - except ValueError: - raise CLIError(f"Invalid UUID format: {args.id}") - - self._print_json(fleet) - - def _inspect_volume(self, args: argparse.Namespace) -> None: - if not args.name: - raise CLIError("Name must be provided") - - try: - volume = self.api.client.volumes.get(project_name=self.api.project, name=args.name) - except ResourceNotExistsError: - console.print("Volume not found") - exit(1) - - self._print_json(volume) - - def _inspect_gateway(self, args: argparse.Namespace) -> None: - if not args.name: - raise CLIError("Name must be provided") - - try: - gateway = self.api.client.gateways.get( - project_name=self.api.project, gateway_name=args.name - ) - except ResourceNotExistsError: - console.print("Gateway not found") - exit(1) - - self._print_json(gateway) - - def _print_json(self, obj: Union[Run, Fleet, Volume, Gateway]) -> None: - print(pydantic_orjson_dumps_with_indent(obj.dict(), default=None)) diff --git a/src/dstack/_internal/cli/commands/run.py b/src/dstack/_internal/cli/commands/run.py new file mode 100644 index 000000000..763c2c0c0 --- /dev/null +++ b/src/dstack/_internal/cli/commands/run.py @@ -0,0 +1,65 @@ +import argparse +from uuid import UUID + +from dstack._internal.cli.commands import APIBaseCommand +from dstack._internal.cli.services.completion import RunNameCompleter +from dstack._internal.cli.utils.common import console +from dstack._internal.core.errors import CLIError, ResourceNotExistsError +from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent + + +class RunCommand(APIBaseCommand): + NAME = "run" + DESCRIPTION = "Manage runs" + + def _register(self): + super()._register() + subparsers = self._parser.add_subparsers(dest="action") + + # TODO: Add `list` subcommand and make `dstack ps` an alias to `dstack run list` + + get_parser = subparsers.add_parser( + "get", help="Get a run", formatter_class=self._parser.formatter_class + ) + name_group = get_parser.add_mutually_exclusive_group(required=True) + name_group.add_argument( + "name", + nargs="?", + metavar="NAME", + help="The name of the run", + ).completer = RunNameCompleter() # type: ignore[attr-defined] + name_group.add_argument( + "--id", + type=str, + help="The ID of the run (UUID)", + ) + get_parser.add_argument( + "--json", + action="store_true", + required=True, + help="Output in JSON format", + ) + get_parser.set_defaults(subfunc=self._get) + + def _command(self, args: argparse.Namespace): + super()._command(args) + if hasattr(args, "subfunc"): + args.subfunc(args) + else: + self._parser.print_help() + + def _get(self, args: argparse.Namespace): + # TODO: Implement non-json output format + try: + if args.id: + run_id = UUID(args.id) + run = self.api.client.runs.get(project_name=self.api.project, run_id=run_id) + else: + run = self.api.client.runs.get(project_name=self.api.project, run_name=args.name) + except ResourceNotExistsError: + console.print(f"Run [code]{args.name or args.id}[/] not found") + exit(1) + except ValueError: + raise CLIError(f"Invalid UUID format: {args.id}") + + print(pydantic_orjson_dumps_with_indent(run.dict(), default=None)) diff --git a/src/dstack/_internal/cli/commands/volume.py b/src/dstack/_internal/cli/commands/volume.py index 3f7da2e00..e78ec352c 100644 --- a/src/dstack/_internal/cli/commands/volume.py +++ b/src/dstack/_internal/cli/commands/volume.py @@ -13,6 +13,7 @@ ) from dstack._internal.cli.utils.volume import get_volumes_table, print_volumes_table from dstack._internal.core.errors import ResourceNotExistsError +from dstack._internal.utils.json_utils import pydantic_orjson_dumps_with_indent class VolumeCommand(APIBaseCommand): @@ -54,6 +55,22 @@ def _register(self): ) delete_parser.set_defaults(subfunc=self._delete) + get_parser = subparsers.add_parser( + "get", help="Get a volume", formatter_class=self._parser.formatter_class + ) + get_parser.add_argument( + "name", + metavar="NAME", + help="The name of the volume", + ).completer = VolumeNameCompleter() # type: ignore[attr-defined] + get_parser.add_argument( + "--json", + action="store_true", + required=True, + help="Output in JSON format", + ) + get_parser.set_defaults(subfunc=self._get) + def _command(self, args: argparse.Namespace): super()._command(args) args.subfunc(args) @@ -88,3 +105,13 @@ def _delete(self, args: argparse.Namespace): self.api.client.volumes.delete(project_name=self.api.project, names=[args.name]) console.print(f"Volume [code]{args.name}[/] deleted") + + def _get(self, args: argparse.Namespace): + # TODO: Implement non-json output format + try: + volume = self.api.client.volumes.get(project_name=self.api.project, name=args.name) + except ResourceNotExistsError: + console.print("Volume not found") + exit(1) + + print(pydantic_orjson_dumps_with_indent(volume.dict(), default=None)) diff --git a/src/dstack/_internal/cli/main.py b/src/dstack/_internal/cli/main.py index 8fe931185..a5f678a98 100644 --- a/src/dstack/_internal/cli/main.py +++ b/src/dstack/_internal/cli/main.py @@ -12,13 +12,13 @@ from dstack._internal.cli.commands.fleet import FleetCommand from dstack._internal.cli.commands.gateway import GatewayCommand from dstack._internal.cli.commands.init import InitCommand -from dstack._internal.cli.commands.inspect import InspectCommand from dstack._internal.cli.commands.login import LoginCommand from dstack._internal.cli.commands.logs import LogsCommand from dstack._internal.cli.commands.metrics import MetricsCommand from dstack._internal.cli.commands.offer import OfferCommand from dstack._internal.cli.commands.project import ProjectCommand from dstack._internal.cli.commands.ps import PsCommand +from dstack._internal.cli.commands.run import RunCommand from dstack._internal.cli.commands.secrets import SecretCommand from dstack._internal.cli.commands.server import ServerCommand from dstack._internal.cli.commands.stop import StopCommand @@ -69,13 +69,13 @@ def main(): FleetCommand.register(subparsers) GatewayCommand.register(subparsers) InitCommand.register(subparsers) - InspectCommand.register(subparsers) OfferCommand.register(subparsers) LoginCommand.register(subparsers) LogsCommand.register(subparsers) MetricsCommand.register(subparsers) ProjectCommand.register(subparsers) PsCommand.register(subparsers) + RunCommand.register(subparsers) SecretCommand.register(subparsers) ServerCommand.register(subparsers) StopCommand.register(subparsers) From 1d3495cb6b47dbf2269c98f2f7e43674890d5c76 Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Thu, 8 Jan 2026 09:14:08 +0100 Subject: [PATCH 4/5] PR review: `dstack run|fleet get --json` Updated how UUID format errors are handled --- src/dstack/_internal/cli/commands/fleet.py | 10 +++++++--- src/dstack/_internal/cli/commands/run.py | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/dstack/_internal/cli/commands/fleet.py b/src/dstack/_internal/cli/commands/fleet.py index f811639c0..5b27971a6 100644 --- a/src/dstack/_internal/cli/commands/fleet.py +++ b/src/dstack/_internal/cli/commands/fleet.py @@ -140,9 +140,15 @@ def _delete(self, args: argparse.Namespace): def _get(self, args: argparse.Namespace): # TODO: Implement non-json output format + fleet_id = None + if args.id: + try: + fleet_id = UUID(args.id) + except ValueError: + raise CLIError(f"Invalid UUID format: {args.id}") + try: if args.id: - fleet_id = UUID(args.id) fleet = self.api.client.fleets.get( project_name=self.api.project, fleet_id=fleet_id ) @@ -151,7 +157,5 @@ def _get(self, args: argparse.Namespace): except ResourceNotExistsError: console.print(f"Fleet [code]{args.name or args.id}[/] not found") exit(1) - except ValueError: - raise CLIError(f"Invalid UUID format: {args.id}") print(pydantic_orjson_dumps_with_indent(fleet.dict(), default=None)) diff --git a/src/dstack/_internal/cli/commands/run.py b/src/dstack/_internal/cli/commands/run.py index 763c2c0c0..3168c92ff 100644 --- a/src/dstack/_internal/cli/commands/run.py +++ b/src/dstack/_internal/cli/commands/run.py @@ -50,16 +50,20 @@ def _command(self, args: argparse.Namespace): def _get(self, args: argparse.Namespace): # TODO: Implement non-json output format + run_id = None + if args.id: + try: + run_id = UUID(args.id) + except ValueError: + raise CLIError(f"Invalid UUID format: {args.id}") + try: if args.id: - run_id = UUID(args.id) run = self.api.client.runs.get(project_name=self.api.project, run_id=run_id) else: run = self.api.client.runs.get(project_name=self.api.project, run_name=args.name) except ResourceNotExistsError: console.print(f"Run [code]{args.name or args.id}[/] not found") exit(1) - except ValueError: - raise CLIError(f"Invalid UUID format: {args.id}") print(pydantic_orjson_dumps_with_indent(run.dict(), default=None)) From 1148df90c590cc92239c5b43ccfae4daaafb5585 Mon Sep 17 00:00:00 2001 From: peterschmidt85 Date: Thu, 8 Jan 2026 09:42:24 +0100 Subject: [PATCH 5/5] PR review: `dstack run|fleet get --json` Better handling edge case (empty ID) --- src/dstack/_internal/cli/commands/fleet.py | 4 ++-- src/dstack/_internal/cli/commands/run.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/dstack/_internal/cli/commands/fleet.py b/src/dstack/_internal/cli/commands/fleet.py index 5b27971a6..130e2c3fc 100644 --- a/src/dstack/_internal/cli/commands/fleet.py +++ b/src/dstack/_internal/cli/commands/fleet.py @@ -141,14 +141,14 @@ def _delete(self, args: argparse.Namespace): def _get(self, args: argparse.Namespace): # TODO: Implement non-json output format fleet_id = None - if args.id: + if args.id is not None: try: fleet_id = UUID(args.id) except ValueError: raise CLIError(f"Invalid UUID format: {args.id}") try: - if args.id: + if args.id is not None: fleet = self.api.client.fleets.get( project_name=self.api.project, fleet_id=fleet_id ) diff --git a/src/dstack/_internal/cli/commands/run.py b/src/dstack/_internal/cli/commands/run.py index 3168c92ff..337b0a75c 100644 --- a/src/dstack/_internal/cli/commands/run.py +++ b/src/dstack/_internal/cli/commands/run.py @@ -51,14 +51,14 @@ def _command(self, args: argparse.Namespace): def _get(self, args: argparse.Namespace): # TODO: Implement non-json output format run_id = None - if args.id: + if args.id is not None: try: run_id = UUID(args.id) except ValueError: raise CLIError(f"Invalid UUID format: {args.id}") try: - if args.id: + if args.id is not None: run = self.api.client.runs.get(project_name=self.api.project, run_id=run_id) else: run = self.api.client.runs.get(project_name=self.api.project, run_name=args.name)