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 }), + }, }, }, diff --git a/src/dstack/_internal/cli/commands/fleet.py b/src/dstack/_internal/cli/commands/fleet.py index c6a11abc3..130e2c3fc 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,25 @@ 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 + fleet_id = None + 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 is not None: + 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) + + 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/run.py b/src/dstack/_internal/cli/commands/run.py new file mode 100644 index 000000000..337b0a75c --- /dev/null +++ b/src/dstack/_internal/cli/commands/run.py @@ -0,0 +1,69 @@ +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 + run_id = None + 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 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) + except ResourceNotExistsError: + console.print(f"Run [code]{args.name or args.id}[/] not found") + exit(1) + + 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 61f3967ab..a5f678a98 100644 --- a/src/dstack/_internal/cli/main.py +++ b/src/dstack/_internal/cli/main.py @@ -18,6 +18,7 @@ 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 @@ -74,6 +75,7 @@ def main(): MetricsCommand.register(subparsers) ProjectCommand.register(subparsers) PsCommand.register(subparsers) + RunCommand.register(subparsers) SecretCommand.register(subparsers) ServerCommand.register(subparsers) StopCommand.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())