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 e4eccda03..ee973bb90 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; } @@ -104,27 +112,36 @@ 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: 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 +151,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; } @@ -197,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() %}
@@ -254,97 +267,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 %} 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