From db72fe3128609b1a6debc925bf79d06cff6c1692 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 6 Sep 2026 21:09:39 -0700 Subject: [PATCH 1/2] fix(guest-agent): tighten the dashboard layout Every value on this page sat inside three stacked surfaces: a white `.info-section` card, a grey rounded `.info-row` inside it, and a white bordered `.info-value` inside that. Three backgrounds to show one string, and the innermost one reads as a disabled text input, which is a claim the page cannot honour -- none of these values are editable. The card stays and the two inner boxes become a hairline between rows. With rows separating themselves the 12px grid gap is redundant, and the 1.6 line-height is a prose setting on a page that is entirely single-line data. Headings were spending about seventy pixels each on margins. A row goes from roughly 60px to 34px. Tables had the opposite problem in the same place: 15px cells made rows tall while the columns stayed too narrow to hold an identifier, so a single GPU row wrapped into three lines of text. Cells shrink, values stop wrapping, and the table scrolls sideways when it has to. That scrolling goes on a wrapper. Setting overflow on the table itself needs display:block, which drops the table formatting context and lets thead and tbody compute their widths independently -- the header bar and the body rows then disagree about where the right edge is. The wrapper also owns the rounded corners and the shadow now, which deletes the four per-corner rules that were placing them on individual cells. --- dstack/guest-agent/templates/dashboard.html | 259 ++++++++++---------- 1 file changed, 134 insertions(+), 125 deletions(-) diff --git a/dstack/guest-agent/templates/dashboard.html b/dstack/guest-agent/templates/dashboard.html index e4eccda03..d026cbe9e 100644 --- a/dstack/guest-agent/templates/dashboard.html +++ b/dstack/guest-agent/templates/dashboard.html @@ -21,9 +21,10 @@ body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; - line-height: 1.6; - padding: 30px; - max-width: 1000px; + /* Data, not prose: 1.6 leading on single-line values reads as gaps. */ + line-height: 1.45; + padding: 24px 20px; + max-width: 1180px; margin: 0 auto; background-color: var(--background-color); color: var(--primary-color); @@ -32,9 +33,18 @@ h1, h2 { color: var(--primary-color); - margin-bottom: 1rem; border-bottom: 2px solid var(--border-color); - padding-bottom: 0.5rem; + padding-bottom: 0.3rem; + } + + h1 { + font-size: 1.6rem; + margin: 0 0 0.6rem; + } + + h2 { + font-size: 1.15rem; + margin: 1.4rem 0 0.6rem; } textarea { @@ -49,45 +59,43 @@ resize: vertical; } + /* Overflow belongs on a wrapper. Setting it on the table needs + display:block, which drops the table formatting context and lets + thead and tbody size themselves independently. */ + .table-scroll { + overflow-x: auto; + margin-top: 12px; + border-radius: 8px; + background-color: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + table { width: 100%; border-collapse: separate; border-spacing: 0; - margin-top: 20px; - background-color: white; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + font-size: 0.92rem; } th, td { - padding: 15px; + padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--border-color); } + /* An identifier split across two lines is harder to read than a table + the reader scrolls sideways. */ + td { + white-space: nowrap; + } + th { background-color: var(--primary-color); color: white; font-weight: 500; } - th:first-child { - border-top-left-radius: 8px; - } - - th:last-child { - border-top-right-radius: 8px; - } - - tr:last-child td:first-child { - border-bottom-left-radius: 8px; - } - - tr:last-child td:last-child { - border-bottom-right-radius: 8px; - } - tbody tr:hover { background-color: #f5f6f7; } @@ -106,25 +114,26 @@ .info-section { background-color: white; - padding: 20px; + padding: 6px 16px; border-radius: 8px; - margin-bottom: 20px; + margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .info-grid { display: grid; - gap: 12px; - margin-top: 16px; } .info-row { display: grid; - grid-template-columns: 200px 1fr; + grid-template-columns: 190px 1fr; align-items: center; - padding: 4px 12px; - background-color: #f8f9fa; - border-radius: 6px; + padding: 5px 4px; + border-bottom: 1px solid var(--border-color); + } + + .info-row:last-child { + border-bottom: none; } .info-label { @@ -134,10 +143,6 @@ .info-value { font-family: monospace; - background-color: white; - padding: 6px 12px; - border-radius: 4px; - border: 1px solid var(--border-color); word-break: break-all; overflow-wrap: break-word; } @@ -254,97 +259,101 @@

GPUs

{%- endmatch %} - - - - - - - - - - - - - - - {% for gpu in gpu_info.gpus %} - - - - - - - - - - - {% endfor %} - -
IndexPCIGPU %Mem %MemoryTempPowerError
{{ gpu.index }}{{ gpu.pci_bus_id|short_bdf }} - {% match gpu.utilization_gpu %} - {% when Some with (value) %}{{ value }}% - {% when None %}- - {% endmatch %} - - {% match gpu.utilization_memory %} - {% when Some with (value) %}{{ value }}% - {% when None %}- - {% endmatch %} - - {% match gpu.memory_used_bytes %} - {% when Some with (used) %} - {% match gpu.memory_total_bytes %} - {% when Some with (total) %}{{ used|hsize }} / {{ total|hsize }} - {% when None %}{{ used|hsize }} - {% endmatch %} - {% when None %} - {% match gpu.memory_total_bytes %} - {% when Some with (total) %}- / {{ total|hsize }} - {% when None %}- - {% endmatch %} - {% endmatch %} - - {% match gpu.temperature_c %} - {% when Some with (value) %}{{ value }} C - {% when None %}- - {% endmatch %} - - {% match gpu.power_usage_mw %} - {% when Some with (value) %}{{ "{:.1}"|format(value as f32 / 1000.0) }} W - {% when None %}- - {% endmatch %} - {{ gpu.errors.join("; ") }}
+
+ + + + + + + + + + + + + + + {% for gpu in gpu_info.gpus %} + + + + + + + + + + + {% endfor %} + +
IndexPCIGPU %Mem %MemoryTempPowerError
{{ gpu.index }}{{ gpu.pci_bus_id|short_bdf }} + {% match gpu.utilization_gpu %} + {% when Some with (value) %}{{ value }}% + {% when None %}- + {% endmatch %} + + {% match gpu.utilization_memory %} + {% when Some with (value) %}{{ value }}% + {% when None %}- + {% endmatch %} + + {% match gpu.memory_used_bytes %} + {% when Some with (used) %} + {% match gpu.memory_total_bytes %} + {% when Some with (total) %}{{ used|hsize }} / {{ total|hsize }} + {% when None %}{{ used|hsize }} + {% endmatch %} + {% when None %} + {% match gpu.memory_total_bytes %} + {% when Some with (total) %}- / {{ total|hsize }} + {% when None %}- + {% endmatch %} + {% endmatch %} + + {% match gpu.temperature_c %} + {% when Some with (value) %}{{ value }} C + {% when None %}- + {% endmatch %} + + {% match gpu.power_usage_mw %} + {% when Some with (value) %}{{ "{:.1}"|format(value as f32 / 1000.0) }} W + {% when None %}- + {% endmatch %} + {{ gpu.errors.join("; ") }}
+
{% endif %} {% endif %}

Deployed Containers

- - - - - - {% if public_logs %} - - {% endif %} - - - - {% for container in containers %} - {% set name = container.names.get(0) %} - - - - {% if public_logs %} - - {% endif %} - - {% endfor %} - -
NameStatusLogs
{{name|cname}}{{container.status}} - View - Logs -
+
+ + + + + + {% if public_logs %} + + {% endif %} + + + + {% for container in containers %} + {% set name = container.names.get(0) %} + + + + {% if public_logs %} + + {% endif %} + + {% endfor %} + +
NameStatusLogs
{{name|cname}}{{container.status}} + View + Logs +
+

TCB Info

{% if public_tcbinfo %} From 05150213492aebce696ebb5cd1346d9a5812fc58 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sun, 6 Sep 2026 21:19:14 -0700 Subject: [PATCH 2/2] fix(guest-agent): correct the uptime and load-average rendering Three defects visible on the same screen as the layout work, all from a unit that was lost between the wire and the reader. `System Uptime` printed `68`. `SystemInfo.uptime` is a `uint64` of seconds and the template printed it raw, so the page gave a number with no unit at any magnitude. It now renders as `1m 8s`. `Load Average` printed `1min: 0.4%`. The wire carries the load times 100, so the page divided it back and then appended a `%`. A load average is a count of runnable tasks, not a percentage: `1.00` on a single-core guest means saturated, not one percent. `/metrics` had the same field wrong in the opposite direction and worse. `dstack_guest_load1` published the undivided integer, so a load of 0.40 was served as `40` under a gauge whose HELP text says "System load average over 1 minute" -- two orders of magnitude off, to a consumer that cannot notice. The deprecated `system_load_average_*` aliases carried the same error. Both now divide. Also scopes the link padding inside table cells. The global `a` rule gives the hover pill 6px of vertical padding, which in a cell sets the row height, so once the cells themselves are tight the one row holding a "View Logs" link stands taller than every other row. Found by Copilot's review of the layout change. --- dstack/guest-agent/src/models.rs | 80 +++++++++++++++++++++ dstack/guest-agent/templates/dashboard.html | 12 +++- dstack/guest-agent/templates/metrics.tpl | 12 ++-- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/dstack/guest-agent/src/models.rs b/dstack/guest-agent/src/models.rs index 97a537251..c8719105b 100644 --- a/dstack/guest-agent/src/models.rs +++ b/dstack/guest-agent/src/models.rs @@ -33,6 +33,40 @@ mod filters { Ok(hex::encode(s)) } + /// Renders a second count as `1d 2h 3m 4s`. `SystemInfo.uptime` is a + /// `uint64` of seconds and the page printed it raw, so a guest one minute + /// into its life displayed `68` with no unit at all. + /// + /// Display only: `dstack_guest_uptime_seconds` keeps the raw count, which + /// is what a Prometheus gauge of that name has to carry. + pub fn hduration(s: &u64) -> Result { + let (days, hours) = (s / 86_400, (s % 86_400) / 3_600); + let (minutes, seconds) = ((s % 3_600) / 60, s % 60); + let mut out = String::new(); + if days > 0 { + out.push_str(&format!("{days}d ")); + } + if days > 0 || hours > 0 { + out.push_str(&format!("{hours}h ")); + } + if days > 0 || hours > 0 || minutes > 0 { + out.push_str(&format!("{minutes}m ")); + } + out.push_str(&format!("{seconds}s")); + Ok(out) + } + + /// Recovers a load average from the fixed-point `u32` the wire carries. + /// + /// `SystemInfo.loadavg_*` is the load times 100, so every consumer has to + /// divide it back. The dashboard did divide, then appended a `%`; a load + /// average is a count of runnable tasks, not a percentage, and `1.00` on a + /// single-core guest means saturated rather than one percent. `/metrics` + /// did not divide at all, so a load of 0.40 was published as `40`. + pub fn load(s: &u32) -> Result { + Ok(format!("{:.2}", f64::from(*s) / 100.0)) + } + /// Drops a zero PCI domain. NVML reports `00000000:01:00.0` where lspci and /// the kernel print `0000:01:00.0` or just `01:00.0`. /// @@ -314,6 +348,52 @@ mod tests { assert!(body.contains(r#"pci_bus_id="00000000:01:00.0""#), "{body}"); } + /// A bare `68` on the page said neither seconds nor minutes. The unit has + /// to survive every magnitude, including the boundary where a field first + /// becomes non-zero. + #[test] + fn uptime_is_rendered_with_units() { + use super::filters::hduration; + + assert_eq!(hduration(&0).unwrap(), "0s"); + assert_eq!(hduration(&68).unwrap(), "1m 8s"); + assert_eq!(hduration(&3_600).unwrap(), "1h 0m 0s"); + assert_eq!(hduration(&90_061).unwrap(), "1d 1h 1m 1s"); + } + + /// The wire carries the load times 100. Both consumers got it wrong in + /// opposite directions: the page divided and then called the result a + /// percentage, `/metrics` published the undivided integer. + #[test] + fn load_average_is_neither_scaled_nor_a_percentage() { + use super::filters::load; + + assert_eq!(load(&40).unwrap(), "0.40"); + assert_eq!(load(&100).unwrap(), "1.00"); + assert_eq!(load(&1_234).unwrap(), "12.34"); + } + + /// A gauge named `load1` reporting 40 for a load of 0.40 is off by two + /// orders of magnitude, which is worse than the cosmetic `%` on the page. + #[test] + fn metrics_publish_load_averages_unscaled() { + let body = Metrics { + system_info: SystemInfo { + loadavg_one: 40, + loadavg_five: 100, + loadavg_fifteen: 1_234, + ..Default::default() + }, + gpu_info: Default::default(), + } + .render() + .expect("render"); + assert!(body.contains("dstack_guest_load1 0.40"), "{body}"); + assert!(body.contains("dstack_guest_load5 1.00"), "{body}"); + assert!(body.contains("dstack_guest_load15 12.34"), "{body}"); + assert!(body.contains("system_load_average_1m 0.40"), "{body}"); + } + fn dashboard_with(gpu_info: GpuInfoResponse) -> String { Dashboard { app_name: String::new(), diff --git a/dstack/guest-agent/templates/dashboard.html b/dstack/guest-agent/templates/dashboard.html index d026cbe9e..ee973bb90 100644 --- a/dstack/guest-agent/templates/dashboard.html +++ b/dstack/guest-agent/templates/dashboard.html @@ -112,6 +112,14 @@ background-color: #edf2f7; } + /* The global link padding gives the hover pill some body, but in a + table cell it also sets the row height -- once the cells themselves + are tight, the one row holding a link stands taller than the rest. */ + td a { + padding: 2px 8px; + margin-left: -8px; + } + .info-section { background-color: white; padding: 6px 16px; @@ -202,11 +210,11 @@

Node Information

Load Average
-
1min: {{system_info.loadavg_one as f32 / 100.0}}%, 5min: {{system_info.loadavg_five as f32 / 100.0}}%, 15min: {{system_info.loadavg_fifteen as f32 / 100.0}}%
+
1min: {{system_info.loadavg_one|load}}, 5min: {{system_info.loadavg_five|load}}, 15min: {{system_info.loadavg_fifteen|load}}
System Uptime
-
{{system_info.uptime}}
+
{{system_info.uptime|hduration}}
{% for (i, disk) in system_info.disks.iter().enumerate() %}
diff --git a/dstack/guest-agent/templates/metrics.tpl b/dstack/guest-agent/templates/metrics.tpl index f80a8e915..6c864c036 100644 --- a/dstack/guest-agent/templates/metrics.tpl +++ b/dstack/guest-agent/templates/metrics.tpl @@ -40,15 +40,15 @@ dstack_guest_uptime_seconds {{system_info.uptime}} # HELP dstack_guest_load1 System load average over 1 minute. # TYPE dstack_guest_load1 gauge -dstack_guest_load1 {{system_info.loadavg_one}} +dstack_guest_load1 {{system_info.loadavg_one|load}} # HELP dstack_guest_load5 System load average over 5 minutes. # TYPE dstack_guest_load5 gauge -dstack_guest_load5 {{system_info.loadavg_five}} +dstack_guest_load5 {{system_info.loadavg_five|load}} # HELP dstack_guest_load15 System load average over 15 minutes. # TYPE dstack_guest_load15 gauge -dstack_guest_load15 {{system_info.loadavg_fifteen}} +dstack_guest_load15 {{system_info.loadavg_fifteen|load}} # HELP dstack_guest_disk_total_bytes Disk size in bytes. # TYPE dstack_guest_disk_total_bytes gauge @@ -205,15 +205,15 @@ system_uptime {{system_info.uptime}} # HELP system_load_average_1m System load average (1 minute) (deprecated: use dstack_guest_load1) # TYPE system_load_average_1m gauge -system_load_average_1m {{system_info.loadavg_one}} +system_load_average_1m {{system_info.loadavg_one|load}} # HELP system_load_average_5m System load average (5 minutes) (deprecated: use dstack_guest_load5) # TYPE system_load_average_5m gauge -system_load_average_5m {{system_info.loadavg_five}} +system_load_average_5m {{system_info.loadavg_five|load}} # HELP system_load_average_15m System load average (15 minutes) (deprecated: use dstack_guest_load15) # TYPE system_load_average_15m gauge -system_load_average_15m {{system_info.loadavg_fifteen}} +system_load_average_15m {{system_info.loadavg_fifteen|load}} # HELP disk_total_size Disk total size in bytes (deprecated: use dstack_guest_disk_total_bytes) # TYPE disk_total_size gauge