Skip to content

Commit 47b1eda

Browse files
authored
Add the dstack event command for viewing events (#3365)
1 parent 4ec4200 commit 47b1eda

6 files changed

Lines changed: 251 additions & 1 deletion

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import argparse
2+
from dataclasses import asdict
3+
4+
from dstack._internal.cli.commands import APIBaseCommand
5+
from dstack._internal.cli.services.events import EventListFilters, EventPaginator, print_event
6+
from dstack._internal.cli.utils.common import (
7+
get_start_time,
8+
)
9+
from dstack._internal.core.models.events import EventTargetType
10+
from dstack._internal.server.schemas.events import LIST_EVENTS_DEFAULT_LIMIT
11+
from dstack.api import Client
12+
13+
14+
class EventCommand(APIBaseCommand):
15+
NAME = "event"
16+
DESCRIPTION = "View events"
17+
18+
def _register(self):
19+
super()._register()
20+
self._parser.set_defaults(subfunc=self._list)
21+
subparsers = self._parser.add_subparsers(dest="action")
22+
23+
list_parser = subparsers.add_parser(
24+
"list",
25+
help="List events within the selected project",
26+
formatter_class=self._parser.formatter_class,
27+
)
28+
list_parser.set_defaults(subfunc=self._list)
29+
30+
for parser in [self._parser, list_parser]:
31+
parser.add_argument(
32+
"--since",
33+
help=(
34+
"Only show events newer than the specified date."
35+
" Can be a duration (e.g. 10s, 5m, 1d) or an RFC 3339 string (e.g. 2023-09-24T15:30:00Z)."
36+
f" If not specified, show the last {LIST_EVENTS_DEFAULT_LIMIT} events."
37+
),
38+
type=str,
39+
)
40+
target_filters_group = parser.add_mutually_exclusive_group()
41+
target_filters_group.add_argument(
42+
"--target-fleet",
43+
action="append",
44+
metavar="NAME",
45+
dest="target_fleets",
46+
help="Only show events that target the specified fleets",
47+
)
48+
target_filters_group.add_argument(
49+
"--target-run",
50+
action="append",
51+
metavar="NAME",
52+
dest="target_runs",
53+
help="Only show events that target the specified runs",
54+
)
55+
within_filters_group = parser.add_mutually_exclusive_group()
56+
within_filters_group.add_argument(
57+
"--within-fleet",
58+
action="append",
59+
metavar="NAME",
60+
dest="within_fleets",
61+
help="Only show events that target the specified fleets or instances within those fleets",
62+
)
63+
within_filters_group.add_argument(
64+
"--within-run",
65+
action="append",
66+
metavar="NAME",
67+
dest="within_runs",
68+
help="Only show events that target the specified runs or jobs within those runs",
69+
)
70+
parser.add_argument(
71+
"--include-target-type",
72+
action="append",
73+
metavar="TYPE",
74+
type=EventTargetType,
75+
dest="include_target_types",
76+
help="Only show events that target entities of the specified types",
77+
)
78+
79+
def _command(self, args: argparse.Namespace):
80+
super()._command(args)
81+
args.subfunc(args)
82+
83+
def _list(self, args: argparse.Namespace):
84+
since = get_start_time(args.since)
85+
filters = _build_filters(args, self.api)
86+
87+
if since is not None:
88+
events = EventPaginator(self.api.client.events).list(
89+
filters=filters, since=since, ascending=True
90+
)
91+
else:
92+
events = reversed(self.api.client.events.list(ascending=False, **asdict(filters)))
93+
try:
94+
for event in events:
95+
print_event(event)
96+
except KeyboardInterrupt:
97+
pass
98+
99+
100+
def _build_filters(args: argparse.Namespace, api: Client) -> EventListFilters:
101+
filters = EventListFilters()
102+
103+
if args.target_fleets:
104+
filters.target_fleets = [
105+
api.client.fleets.get(api.project, name).id for name in args.target_fleets
106+
]
107+
elif args.target_runs:
108+
filters.target_runs = [
109+
api.client.runs.get(api.project, name).id for name in args.target_runs
110+
]
111+
112+
if args.within_fleets:
113+
filters.within_fleets = [
114+
api.client.fleets.get(api.project, name).id for name in args.within_fleets
115+
]
116+
elif args.within_runs:
117+
filters.within_runs = [
118+
api.client.runs.get(api.project, name).id for name in args.within_runs
119+
]
120+
else:
121+
filters.within_projects = [api.client.projects.get(api.project).project_id]
122+
123+
if args.include_target_types:
124+
filters.include_target_types = args.include_target_types
125+
126+
return filters

src/dstack/_internal/cli/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from dstack._internal.cli.commands.attach import AttachCommand
99
from dstack._internal.cli.commands.completion import CompletionCommand
1010
from dstack._internal.cli.commands.delete import DeleteCommand
11+
from dstack._internal.cli.commands.event import EventCommand
1112
from dstack._internal.cli.commands.fleet import FleetCommand
1213
from dstack._internal.cli.commands.gateway import GatewayCommand
1314
from dstack._internal.cli.commands.init import InitCommand
@@ -62,6 +63,7 @@ def main():
6263
ApplyCommand.register(subparsers)
6364
AttachCommand.register(subparsers)
6465
DeleteCommand.register(subparsers)
66+
EventCommand.register(subparsers)
6567
FleetCommand.register(subparsers)
6668
GatewayCommand.register(subparsers)
6769
InitCommand.register(subparsers)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import uuid
2+
from collections.abc import Iterator
3+
from dataclasses import asdict, dataclass
4+
from datetime import datetime
5+
from typing import Optional
6+
7+
from rich.text import Text
8+
9+
from dstack._internal.cli.utils.common import console
10+
from dstack._internal.core.models.events import Event, EventTargetType
11+
from dstack._internal.server.schemas.events import LIST_EVENTS_DEFAULT_LIMIT
12+
from dstack.api.server._events import EventsAPIClient
13+
14+
15+
@dataclass
16+
class EventListFilters:
17+
target_fleets: Optional[list[uuid.UUID]] = None
18+
target_runs: Optional[list[uuid.UUID]] = None
19+
within_projects: Optional[list[uuid.UUID]] = None
20+
within_fleets: Optional[list[uuid.UUID]] = None
21+
within_runs: Optional[list[uuid.UUID]] = None
22+
include_target_types: Optional[list[EventTargetType]] = None
23+
24+
25+
class EventPaginator:
26+
def __init__(self, client: EventsAPIClient) -> None:
27+
self._client = client
28+
29+
def list(
30+
self, filters: EventListFilters, since: Optional[datetime], ascending: bool
31+
) -> Iterator[Event]:
32+
prev_id = None
33+
prev_recorded_at = since
34+
while True:
35+
events = self._client.list(
36+
prev_id=prev_id,
37+
prev_recorded_at=prev_recorded_at,
38+
limit=LIST_EVENTS_DEFAULT_LIMIT,
39+
ascending=ascending,
40+
**asdict(filters),
41+
)
42+
for event in events:
43+
yield event
44+
if len(events) < LIST_EVENTS_DEFAULT_LIMIT:
45+
break
46+
prev_id = events[-1].id
47+
prev_recorded_at = events[-1].recorded_at
48+
49+
50+
def print_event(event: Event) -> None:
51+
recorded_at = event.recorded_at.astimezone().strftime("%Y-%m-%d %H:%M:%S")
52+
targets = ", ".join(f"{target.type} {target.name}" for target in event.targets)
53+
message = [
54+
Text(f"[{recorded_at}]", style="log.time"),
55+
Text(event.message, style="log.message"),
56+
Text(f"[{targets}]", style="secondary"),
57+
]
58+
if event.actor_user:
59+
message.append(Text(f"👤 {event.actor_user}"))
60+
console.print(
61+
*message,
62+
soft_wrap=True, # Strictly one line per event. Allows for grepping
63+
)

src/dstack/_internal/server/schemas/events.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
MIN_FILTER_ITEMS = 1
1212
MAX_FILTER_ITEMS = 16 # Conservative limit to prevent overly complex db queries
13+
LIST_EVENTS_DEFAULT_LIMIT = 100
1314

1415

1516
class ListEventsRequest(CoreModel):
@@ -142,7 +143,7 @@ class ListEventsRequest(CoreModel):
142143
] = None
143144
prev_recorded_at: Optional[datetime] = None
144145
prev_id: Optional[UUID] = None
145-
limit: int = Field(100, ge=1, le=100)
146+
limit: int = Field(LIST_EVENTS_DEFAULT_LIMIT, ge=1, le=100)
146147
ascending: bool = False
147148

148149
@root_validator

src/dstack/api/server/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
)
1616
from dstack._internal.utils.logging import get_logger
1717
from dstack.api.server._backends import BackendsAPIClient
18+
from dstack.api.server._events import EventsAPIClient
1819
from dstack.api.server._files import FilesAPIClient
1920
from dstack.api.server._fleets import FleetsAPIClient
2021
from dstack.api.server._gateways import GatewaysAPIClient
@@ -122,6 +123,10 @@ def volumes(self) -> VolumesAPIClient:
122123
def files(self) -> FilesAPIClient:
123124
return FilesAPIClient(self._request, self._logger)
124125

126+
@property
127+
def events(self) -> EventsAPIClient:
128+
return EventsAPIClient(self._request, self._logger)
129+
125130
def get_token_hash(self) -> str:
126131
return hashlib.sha1(self._token.encode()).hexdigest()[:8]
127132

src/dstack/api/server/_events.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from datetime import datetime, timezone
2+
from typing import Optional
3+
from uuid import UUID
4+
5+
from pydantic import parse_obj_as
6+
7+
from dstack._internal.core.models.events import Event, EventTargetType
8+
from dstack._internal.server.schemas.events import LIST_EVENTS_DEFAULT_LIMIT, ListEventsRequest
9+
from dstack.api.server._group import APIClientGroup
10+
11+
12+
class EventsAPIClient(APIClientGroup):
13+
def list(
14+
self,
15+
target_projects: Optional[list[UUID]] = None,
16+
target_users: Optional[list[UUID]] = None,
17+
target_fleets: Optional[list[UUID]] = None,
18+
target_instances: Optional[list[UUID]] = None,
19+
target_runs: Optional[list[UUID]] = None,
20+
target_jobs: Optional[list[UUID]] = None,
21+
within_projects: Optional[list[UUID]] = None,
22+
within_fleets: Optional[list[UUID]] = None,
23+
within_runs: Optional[list[UUID]] = None,
24+
include_target_types: Optional[list[EventTargetType]] = None,
25+
actors: Optional[list[Optional[UUID]]] = None,
26+
prev_recorded_at: Optional[datetime] = None,
27+
prev_id: Optional[UUID] = None,
28+
limit: int = LIST_EVENTS_DEFAULT_LIMIT,
29+
ascending: bool = False,
30+
) -> list[Event]:
31+
if prev_recorded_at is not None:
32+
# Time zones other than UTC are misinterpreted by the server:
33+
# https://github.com/dstackai/dstack/issues/3354
34+
prev_recorded_at = prev_recorded_at.astimezone(timezone.utc)
35+
req = ListEventsRequest(
36+
target_projects=target_projects,
37+
target_users=target_users,
38+
target_fleets=target_fleets,
39+
target_instances=target_instances,
40+
target_runs=target_runs,
41+
target_jobs=target_jobs,
42+
within_projects=within_projects,
43+
within_fleets=within_fleets,
44+
within_runs=within_runs,
45+
include_target_types=include_target_types,
46+
actors=actors,
47+
prev_recorded_at=prev_recorded_at,
48+
prev_id=prev_id,
49+
limit=limit,
50+
ascending=ascending,
51+
)
52+
resp = self._request("/api/events/list", body=req.json())
53+
return parse_obj_as(list[Event.__response__], resp.json())

0 commit comments

Comments
 (0)