1- from datetime import datetime , timedelta
1+ from datetime import datetime , timedelta , timezone
22from typing import Any , List , Optional , Sequence
33
44from rich .console import RenderableType
1010from dstack ._internal .core .models .instances import Resources
1111from dstack ._internal .core .models .metrics import JobMetrics
1212from dstack ._internal .core .models .runs import Job
13- from dstack ._internal .utils .common import pretty_date
1413
1514MAX_SAMPLES = 1000
1615"""A sample count, not a window: outruns the hour a running job retains, so a young run is
2625"""What the server keeps for a running job, and so the widest window there can be."""
2726
2827AXIS_RULE = "┄"
28+ NOW = "now"
29+
30+ LIVE_THRESHOLD = timedelta (seconds = 3 * WATCH_INTERVAL_SECONDS )
2931_FIXED_COLUMNS = 30
3032"""Everything but the sparklines and the job label: the `gpu=N` column, both numbers, and
3133the table's padding. Hand-measured against a `589GB/1480GB`-sized number; a wider one
@@ -47,7 +49,7 @@ def get_metrics_table(
4749 labels = job_labels (jobs )
4850 label_width = max ((len (label ) for label in labels ), default = 0 )
4951 width = _spark_width (console_width or console .width , label_width )
50- span = _span (metrics )
52+ window = _shared_window (metrics )
5153
5254 table = Table (box = None )
5355 # no headers: the cells read `replica=0` and `gpu=1`, which need no naming
@@ -59,24 +61,19 @@ def get_metrics_table(
5961 for index , (job , job_metrics ) in enumerate (zip (jobs , metrics )):
6062 if index :
6163 table .add_row ("" , "" , "" , "" )
62- _add_job (table , job , job_metrics , width , labels [index ], span )
64+ _add_job (table , job , job_metrics , width , labels [index ], window )
6365
64- if span is not None :
66+ if window is not None :
6567 table .add_row ("" , "" , "" , "" )
6668 # the axis spans the widest chart drawn: a job with fewer samples than cells draws
6769 # one cell per sample and cannot fill its share
68- axis = _axis (max (_drawn (m , span , width ) for m in metrics ), * span )
70+ axis = _time_axis (max (_cells_drawn (m , window , width ) for m in metrics ), * window )
6971 table .add_row ("" , "" , axis , axis )
7072 return table
7173
7274
7375def job_labels (jobs : Sequence [Job ]) -> List [str ]:
74- """`replica=`/`group=` only where they distinguish something, as `dstack ps` does --
75- one replica across four nodes is `job=0..3`, not `replica=0 job=0..3`.
76-
77- Unlike `ps`, `job=` is always printed. This table is keyed by job, so every row names
78- one; `replica=` joins it only where there is more than one replica to tell apart.
79- """
76+ """A label per job, naming only what tells them apart, as `dstack ps` does."""
8077 groups = {job .job_spec .replica_group for job in jobs }
8178 show_group = len (groups ) > 1
8279 show_replica = len ({job .job_spec .replica_num for job in jobs }) > 1
@@ -102,59 +99,60 @@ def _add_job(
10299 metrics : JobMetrics ,
103100 width : int ,
104101 label : str ,
105- span : Optional [tuple [datetime , datetime ]],
102+ window : Optional [tuple [datetime , datetime ]],
106103) -> None :
107104 resources = _get_resources (job )
108- lead = _lead (metrics , span , width )
109- cells = width - lead
105+ blanks = _blank_cells (metrics , window , width )
106+ cells = width - blanks
110107 table .add_row (
111108 label ,
112109 "cpu" ,
113- _pad (_cpu_cell (metrics , resources , cells ), lead ),
114- _pad (_memory_cell (metrics , resources , cells ), lead ),
110+ _pad (_cpu_cell (metrics , resources , cells ), blanks ),
111+ _pad (_memory_cell (metrics , resources , cells ), blanks ),
115112 )
116113 for index in range (_gpus_num (metrics , resources )):
117114 table .add_row (
118115 "" ,
119116 f"gpu={ index } " ,
120- _pad (_gpu_util_cell (metrics , index , cells ), lead ),
121- _pad (_gpu_memory_cell (metrics , resources , index , cells ), lead ),
117+ _pad (_gpu_util_cell (metrics , index , cells ), blanks ),
118+ _pad (_gpu_memory_cell (metrics , resources , index , cells ), blanks ),
122119 )
123120
124121
125- def _span (metrics : Sequence [JobMetrics ]) -> Optional [tuple [datetime , datetime ]]:
126- """The window every job is drawn against: always the full retention hour.
127-
128- Fixed rather than fitted to the data, so a row means the same thing in every
129- invocation and across every job. A job younger than the hour fills only its share of
130- the row and the rest is blank -- which is the fact worth seeing about a replica that
131- started two minutes ago.
132- """
133- windows = [w for w in (_window (m ) for m in metrics ) if w is not None ]
122+ def _shared_window (metrics : Sequence [JobMetrics ]) -> Optional [tuple [datetime , datetime ]]:
123+ """The window every job is charted against: the newest sample back one retention hour."""
124+ windows = [w for w in (_job_window (m ) for m in metrics ) if w is not None ]
134125 if not windows :
135126 return None
136127 latest , earliest = max (w [1 ] for w in windows ), min (w [0 ] for w in windows )
137128 return min (earliest , latest - RETENTION ), latest
138129
139130
140- def _lead (metrics : JobMetrics , span : Optional [tuple [datetime , datetime ]], width : int ) -> int :
141- """Cells before this job's first sample -- time it was not running for."""
142- window = _window (metrics )
143- if window is None or span is None :
131+ def _blank_cells (
132+ metrics : JobMetrics , window : Optional [tuple [datetime , datetime ]], width : int
133+ ) -> int :
134+ """How many cells to leave empty before a job's chart, so it starts where it started."""
135+ job_window = _job_window (metrics )
136+ if job_window is None or window is None :
144137 return 0
145- total = (span [1 ] - span [0 ]).total_seconds ()
138+ total = (window [1 ] - window [0 ]).total_seconds ()
146139 if total <= 0 :
147140 return 0
148- return min (width - 1 , max (0 , round ((window [0 ] - span [0 ]).total_seconds () / total * width )))
141+ started = (job_window [0 ] - window [0 ]).total_seconds ()
142+ return min (width - 1 , max (0 , round (started / total * width )))
149143
150144
151- def _drawn (metrics : JobMetrics , span : Optional [tuple [datetime , datetime ]], width : int ) -> int :
152- lead = _lead (metrics , span , width )
153- return lead + min (width - lead , _samples_num (metrics ))
145+ def _cells_drawn (
146+ metrics : JobMetrics , window : Optional [tuple [datetime , datetime ]], width : int
147+ ) -> int :
148+ """How many cells a job's chart occupies: its empty lead plus one per sample."""
149+ blanks = _blank_cells (metrics , window , width )
150+ return blanks + min (width - blanks , _samples_num (metrics ))
154151
155152
156- def _pad (cell : Text , lead : int ) -> Text :
157- return cell if lead <= 0 else Text .assemble (Text (" " * lead ), cell )
153+ def _pad (cell : Text , blanks : int ) -> Text :
154+ """Prefix a chart cell with empty cells, for time before the job started."""
155+ return cell if blanks <= 0 else Text .assemble (Text (" " * blanks ), cell )
158156
159157
160158def _cpu_cell (job_metrics : JobMetrics , resources : Optional [Resources ], width : int ) -> Text :
@@ -166,15 +164,15 @@ def _cpu_cell(job_metrics: JobMetrics, resources: Optional[Resources], width: in
166164 values = [v / cpus for v in values ]
167165 # no core count: the value is already normalised to it, and unlike memory there is no
168166 # total to give the number meaning
169- return _cell (sparkline (values , width , HOST_RAMP ), f"{ values [- 1 ]:.0f} %" )
167+ return Text . assemble (sparkline (values , width , HOST_RAMP ), " " , f"{ values [- 1 ]:.0f} %" )
170168
171169
172170def _memory_cell (job_metrics : JobMetrics , resources : Optional [Resources ], width : int ) -> Text :
173171 values = _metric_values (job_metrics , "memory_working_set_bytes" )
174172 if not values :
175173 return no_data ()
176174 total = resources .memory_mib * 1024 * 1024 if resources else None
177- return _level_cell (values , total , width , HOST_RAMP )
175+ return _capacity_cell (values , total , width , HOST_RAMP )
178176
179177
180178def _gpu_memory_cell (
@@ -189,65 +187,54 @@ def _gpu_memory_cell(
189187 total = None
190188 if resources and index < len (resources .gpus ):
191189 total = resources .gpus [index ].memory_mib * 1024 * 1024
192- return _level_cell (values , total , width , GPU_RAMP )
190+ return _capacity_cell (values , total , width , GPU_RAMP )
193191
194192
195193def _gpu_util_cell (job_metrics : JobMetrics , index : int , width : int ) -> Text :
196194 values = _metric_values (job_metrics , f"gpu_util_percent_gpu{ index } " )
197195 if not values :
198196 return no_data ()
199- return _cell (sparkline (values , width , GPU_RAMP ), f"{ values [- 1 ]:.0f} %" )
197+ return Text . assemble (sparkline (values , width , GPU_RAMP ), " " , f"{ values [- 1 ]:.0f} %" )
200198
201199
202- def _level_cell (values : List [float ], total : Optional [float ], width : int , ramp : Ramp ) -> Text :
200+ def _capacity_cell (values : List [float ], total : Optional [float ], width : int , ramp : Ramp ) -> Text :
201+ """A memory chart drawn against capacity, labelled `used/total`."""
203202 percents = [v / total * 100 for v in values ] if total else values
204203 label = format_memory (values [- 1 ], 0 )
205204 if total :
206205 label += f"/{ format_memory (total , 0 )} "
207- return _cell (sparkline (percents , width , ramp ), label )
208-
209-
210- def _cell (spark : Text , label : str ) -> Text :
211- return Text .assemble (spark , " " , label )
212-
213-
214- def _axis (width : int , first : datetime , last : datetime ) -> Text :
215- """`<oldest> ┄┄┄ <newest>`, never wider than the sparkline above it.
206+ return Text .assemble (sparkline (percents , width , ramp ), " " , label )
216207
217- The rule is what pairs the two stamps. UTILIZATION and MEMORY each print one, so the
218- row ends up holding four times, and with the rule left blank the only cue is spacing --
219- which points the wrong way above 88 columns: at 200 there are 66 blanks between a
220- column's own two stamps but only 13 between the columns, so each column's newest time
221- reads as belonging to the next column's oldest.
222208
223- A run draws one cell per sample, so for its first few minutes there are fewer cells
224- than two dates need. Dropping the date keeps the axis inside its cell; overflowing
225- instead widens the column and pulls MEMORY out of line with the charts.
226- """
227- left , right = _stamp (first ), _stamp (last )
209+ def _time_axis (width : int , first : datetime , last : datetime ) -> Text :
210+ """The timeline row printed under the charts, exactly `width` columns wide."""
211+ left , right = _time_label (first ), _time_label (last )
228212 if len (left ) + len (right ) + 3 > width :
229- left , right = _stamp (first , clock_only = True ), _stamp (last , clock_only = True )
213+ left , right = _time_label (first , clock_only = True ), _time_label (last , clock_only = True )
230214 if len (left ) + len (right ) + 2 > width :
231215 return Text ("" )
232216 fill = width - len (left ) - len (right ) - 2
233- return Text (f"{ left } " + AXIS_RULE * fill + f" { right } " , style = "grey42" )
217+ axis = Text (f"{ left } " + AXIS_RULE * fill + " " , style = "grey42" )
218+ axis .append (right , style = "bold grey58" if right == NOW else "grey42" )
219+ return axis
234220
235221
236- def _stamp (moment : datetime , clock_only : bool = False ) -> str :
237- if pretty_date (moment ) == "now" :
238- return "now"
222+ def _time_label (moment : datetime , clock_only : bool = False ) -> str :
223+ """One timestamp for the axis: `now` while a job is still reporting, a date otherwise."""
224+ if datetime .now (timezone .utc ) - moment < LIVE_THRESHOLD :
225+ return NOW
239226 local = moment .astimezone ()
240227 return f"{ local :%H:%M} " if clock_only else f"{ local .day } { local :%b %H:%M} "
241228
242229
243- def _window (job_metrics : JobMetrics ) -> Optional [tuple [datetime , datetime ]]:
230+ def _job_window (job_metrics : JobMetrics ) -> Optional [tuple [datetime , datetime ]]:
231+ """The oldest and newest sample timestamps of one job, or None if it has none."""
244232 stamps = [t for metric in job_metrics .metrics for t in metric .timestamps ]
245233 return (min (stamps ), max (stamps )) if stamps else None
246234
247235
248236def _samples_num (job_metrics : JobMetrics ) -> int :
249- """`slices` never draws more cells than it has samples, so the axis must stop there
250- too -- else it claims a span nothing was measured over, and Rich widens the column."""
237+ """How many samples the longest series holds."""
251238 return max ((len (metric .timestamps ) for metric in job_metrics .metrics ), default = 0 )
252239
253240
@@ -260,15 +247,15 @@ def _metric_values(job_metrics: JobMetrics, name: str) -> List[Any]:
260247 return []
261248
262249
263- def _latest (job_metrics : JobMetrics , name : str ) -> Optional [Any ]:
250+ def _latest_value (job_metrics : JobMetrics , name : str ) -> Optional [Any ]:
264251 values = _metric_values (job_metrics , name )
265252 return values [- 1 ] if values else None
266253
267254
268255def _gpus_num (job_metrics : JobMetrics , resources : Optional [Resources ]) -> int :
269256 if resources is not None and resources .gpus :
270257 return len (resources .gpus )
271- detected = _latest (job_metrics , "gpus_detected_num" )
258+ detected = _latest_value (job_metrics , "gpus_detected_num" )
272259 return int (detected ) if detected else 0
273260
274261
0 commit comments