From c56c70381b83f77f98a20e93a52d2518176bd2b6 Mon Sep 17 00:00:00 2001 From: Seth <80608102+xptea@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:55:18 -0400 Subject: [PATCH 1/2] Add interactive chart time navigation --- AGENTS.md | 80 ++++++++ CHANGELOG.md | 18 +- README.md | 15 +- src/main.c | 441 +++++++++++++++++++++++++++++++++++++++++++- src/version.h | 4 +- src/vgpu.h | 3 +- tests/test_conpty.c | 24 ++- 7 files changed, 567 insertions(+), 18 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d86dc2c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# AGENTS.md + +This file is the working guide for coding agents and contributors changing VGPU-Mon. + +## Project shape + +VGPU-Mon is a native 64-bit Windows GPU process monitor written in C11. The supported build toolchain is MSVC from Visual Studio 2022. The interactive UI uses Win32 console input plus VT output; it is not a curses application. + +Important areas: + +- `src/main.c`: application state, sampling loop, terminal rendering, keyboard/mouse input, CLI output +- `src/nvml_dyn.c`: dynamically loaded NVIDIA NVML telemetry +- `src/dxgi_gpu.c`: DXGI adapter and memory telemetry +- `src/pdh_gpu.c`: WDDM/PDH engine and per-process counters +- `src/updater.c`: signed-release metadata checks, download verification, and update handoff +- `tests/test_core.c`: native unit and CLI behavior tests +- `tests/test_conpty.c`: full terminal lifecycle, input, resize, and frame-bound tests +- `tests/test_installer.ps1`: install, PATH, upgrade, and uninstall lifecycle +- `tools/`: release metadata, PE-hardening, dependency, and packaging checks + +Read `docs/architecture.md`, `CONTRIBUTING.md`, and `SECURITY.md` before changing provider ownership, process termination, installation, or update behavior. + +## Build and verification + +Run commands from the repository root in PowerShell. The PowerShell/MSVC path is release-authoritative. + +```powershell +.\build.ps1 -Configuration Debug -Test +.\build.ps1 -Configuration Release -Test +.\build.ps1 -Configuration Sanitize -Test -OutputName vgpu-mon-asan +.\analyze.ps1 +.\tools\verify-pe.ps1 +``` + +For a release candidate, also run: + +```powershell +.\package.ps1 -SkipBuild +.\installer.ps1 -SkipBuild +.\tests\test_installer.ps1 -InstallerPath (Get-ChildItem .\dist\VGPU-Mon-*-setup.exe -File).FullName +``` + +Use `--demo` for UI work that must not depend on the local GPU: + +```powershell +.\build\vgpu-mon.exe --demo +.\build\vgpu-mon.exe --demo --chart vram +``` + +Do not commit `build/`, `dist/`, telemetry logs, installers, or machine-specific output. + +## Coding rules + +- Keep C warnings clean under `/W4 /WX /sdl` and static analysis. +- Use four-space indentation, bounded buffers, explicit ownership, and one cleanup path per acquired resource. +- Preserve graceful behavior when NVML, DXGI, PDH, a sensor, or an individual process query is unavailable. +- Do not add a mandatory runtime, SDK, administrator requirement, service, or driver. +- Keep optional vendor APIs dynamically discovered and keep AMD/Intel fallback behavior intact. +- Add or update automated tests for terminal input, resize, CLI parsing, cleanup, updater, or installer behavior changes. +- Update `README.md` and `CHANGELOG.md` for user-visible changes. Bump `src/version.h` only for a release-ready user-facing change. + +## Terminal rendering invariants + +- Every repaint must compose a complete frame for the current viewport. +- Never write the terminal's final column; a pending wrap can scroll or flash the viewport. +- Keep emitted rows within the current visible height and clear stale line/tail content. +- Mouse coordinates are console-buffer coordinates; normalize them with `viewport_left` and `viewport_top`. +- Resize, mouse, and chart changes must continue to pass the ConPTY bounds test. +- Chart history is a timestamped ring. Time labels and hover values must use stored timestamps, not assume the current refresh interval applied to older samples. +- A historical chart view should remain anchored while new samples arrive. `End` returns it to live data. + +## Safety and release rules + +- Treat process names, executable paths, UUIDs, and logs as potentially sensitive. +- Keep termination confirmation and protected/self-process checks intact. +- Never weaken HTTPS-only downloads, version parsing, release filename validation, SHA-256 verification, or current-user installer behavior. +- Do not publish a release unless Debug, Release, AddressSanitizer, static analysis, PE hardening, packaging, and installer lifecycle checks pass. +- Release tags must exactly match `VGPU_VERSION`, and release assets must include the installer, portable ZIP, bootstrap script, `version.txt`, and `SHA256SUMS.txt`. + +When a check cannot run, report that explicitly; do not describe unverified work as production-ready. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f4a119..b1bdf4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable user-facing changes are documented here. This project follows [Seman ## [Unreleased] +## [1.3.0] - 2026-07-13 + +### Added + +- Zoom the chart's visible time span with the mouse wheel, from a tight live view through the retained history. +- Pan older/newer with `Shift`+wheel, arrow keys, or page keys, with `Home` for the oldest complete window and `End` for live data. +- Show an in-chart crosshair tooltip with the nearest real sample's exact value and age. +- Retain 14,400 timestamped samples per metric in a bounded ring and preserve historical view position while new samples arrive. +- Add a repository-level `AGENTS.md` with architecture, testing, terminal-rendering, security, and release guidance. + +### Security and quality + +- Keep chart time calculations correct across live refresh-interval changes by recording monotonic timestamps per sample. +- Exercise chart wheel, pan, hover, resize, and complete-frame bounds through the ConPTY integration test. + ## [1.2.0] - 2026-07-13 ### Added @@ -70,7 +85,8 @@ All notable user-facing changes are documented here. This project follows [Seman - Added `/W4 /WX /sdl`, control-flow protection, ASLR/DEP/CET-compatible linker flags, reproducible Release builds, MSVC AddressSanitizer tests, CRT leak checks, static analysis, CodeQL, and pinned CI dependencies. -[Unreleased]: https://github.com/xptea/VGPU-Mon/compare/v1.2.0...HEAD +[Unreleased]: https://github.com/xptea/VGPU-Mon/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/xptea/VGPU-Mon/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/xptea/VGPU-Mon/compare/v1.1.4...v1.2.0 [1.1.4]: https://github.com/xptea/VGPU-Mon/compare/v1.1.3...v1.1.4 [1.1.3]: https://github.com/xptea/VGPU-Mon/compare/v1.1.2...v1.1.3 diff --git a/README.md b/README.md index c672225..d421b57 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ It is a real full-screen terminal application, not a wrapper around `nvidia-smi` ## Features - Live GPU and physical VRAM utilization with driver-reserved memory separated -- Full-screen scrolling charts for GPU, VRAM, engine, temperature, and power metrics +- Full-screen timestamped charts with time-span zoom, history panning, and exact hover values - WDDM dedicated/shared GPU-memory commitments per process - Per-process 3D, compute, copy, encode, and decode engine activity - Temperature, board power, power limit, fan, P-state, and clocks through NVML - Encoder, decoder, and PCIe link/throughput telemetry when the driver exposes it - Multiple-GPU switching -- Mouse-clickable sort headers and process rows, plus mouse-wheel navigation +- Mouse-clickable sort headers and process rows, plus context-aware wheel navigation - Responsive layouts with bounded full-frame repainting after every terminal resize - Interactive sorting, process-name filtering, and process details - Confirmed process termination with protection for system and self PIDs @@ -125,7 +125,12 @@ winget install --id JRSoftware.InnoSetup --exact --scope user | `p`, `n`, `e` | Sort by PID, name, or engine | | `o` | Reverse the current sort | | Mouse click | Sort a header or select a process row | -| Mouse wheel | Move through process rows | +| Mouse wheel | Move through process rows; zoom the time span over a chart | +| `Shift` + mouse wheel | Pan backward or forward through chart history | +| `Left` / `Right` | Pan a chart by one-quarter of its visible span | +| `PgUp` / `PgDn` | Pan a chart by one full visible span | +| `Home` / `End` | Jump to the oldest complete chart window or return live | +| Mouse hover | Show the exact chart value and how long ago it was sampled | | `f` | Edit the process-name filter | | `d` | Toggle details for the selected process | | `i` | Toggle the full GPU information panel | @@ -177,6 +182,10 @@ Demo values are clearly labeled and must not be treated as machine telemetry. The flag form requested by the UI is also supported, such as `--chart --vram`, `--chart --3d`, or simply `--vram`. In chart view, number keys switch metrics without restarting and `c` returns to the process table. +Charts open on a one-minute live window. Scroll the mouse wheel over the plot to zoom from a tighter view to the full retained range. Use `Shift`+wheel, `Left`/`Right`, or `PgUp`/`PgDn` to inspect older samples; `End` snaps back to live data. Hovering anywhere on the plot draws a crosshair and an in-chart tooltip with the nearest real sample's value and age. + +VGPU-Mon retains 14,400 timestamped samples per metric in a fixed-size ring, so memory use stays bounded. That represents about four hours at the default one-second interval, one hour at 250 ms, or twenty hours at five seconds. Stored timestamps keep zoom, panning, and hover ages correct when the sampling interval changes during a run. History is in memory only and resets when the app closes or the selected GPU changes; use CSV logging for long-term storage. + Options: ```text diff --git a/src/main.c b/src/main.c index 8323f2a..1a5f78f 100644 --- a/src/main.c +++ b/src/main.c @@ -123,8 +123,21 @@ typedef struct { size_t history_count; size_t history_next; double chart_history[CHART_METRIC_COUNT][VGPU_CHART_HISTORY_SIZE]; + ULONGLONG chart_history_time[VGPU_CHART_HISTORY_SIZE]; size_t chart_history_count; size_t chart_history_next; + ULONGLONG chart_window_ms; + ULONGLONG chart_pan_ms; + bool chart_hover_active; + int chart_hover_x; + int chart_hover_y; + int chart_plot_left; + int chart_plot_right; + int chart_plot_top; + int chart_plot_bottom; + double *chart_plot_values; + unsigned char *chart_plot_present; + size_t chart_plot_capacity; FILE *log_file; char log_path[MAX_PATH]; ProcessDetails details_cache; @@ -302,6 +315,14 @@ static void update_visible_processes(App *app) { } static void add_history_sample(App *app) { + ULONGLONG sampled_at = GetTickCount64(); + if (app->chart_history_count > 0 && app->chart_pan_ms > 0) { + size_t newest = (app->chart_history_next + VGPU_CHART_HISTORY_SIZE - 1) % + VGPU_CHART_HISTORY_SIZE; + ULONGLONG previous = app->chart_history_time[newest]; + if (sampled_at > previous) app->chart_pan_ms += sampled_at - previous; + } + app->utilization_history[app->history_next] = app->telemetry.gpu_util; double memory_percent = app->telemetry.memory_total ? (double)app->telemetry.memory_used * 100.0 / (double)app->telemetry.memory_total : 0.0; @@ -322,6 +343,7 @@ static void add_history_sample(App *app) { values[CHART_POWER] = app->telemetry.power_w >= 0 ? app->telemetry.power_w : 0.0; for (size_t i = 0; i < CHART_METRIC_COUNT; ++i) app->chart_history[i][app->chart_history_next] = values[i]; + app->chart_history_time[app->chart_history_next] = sampled_at; app->chart_history_next = (app->chart_history_next + 1) % VGPU_CHART_HISTORY_SIZE; if (app->chart_history_count < VGPU_CHART_HISTORY_SIZE) app->chart_history_count++; } @@ -371,6 +393,135 @@ static bool chart_metric_available(const App *app, ChartMetric metric) { } } +static size_t chart_oldest_index(const App *app) { + return app->chart_history_count < VGPU_CHART_HISTORY_SIZE ? 0 : app->chart_history_next; +} + +static size_t chart_ring_index(const App *app, size_t logical) { + return (chart_oldest_index(app) + logical) % VGPU_CHART_HISTORY_SIZE; +} + +static ULONGLONG chart_timestamp(const App *app, size_t logical) { + return app->chart_history_time[chart_ring_index(app, logical)]; +} + +static double chart_value(const App *app, ChartMetric metric, size_t logical) { + return app->chart_history[metric][chart_ring_index(app, logical)]; +} + +static ULONGLONG chart_max_window_ms(const App *app) { + ULONGLONG maximum = (ULONGLONG)app->interval_ms * (ULONGLONG)VGPU_CHART_HISTORY_SIZE; + ULONGLONG duration = 0; + if (app->chart_history_count > 1) { + duration = chart_timestamp(app, app->chart_history_count - 1) - chart_timestamp(app, 0); + } + if (duration > maximum) maximum = duration; + return maximum < 10000ULL ? 10000ULL : maximum; +} + +static void clamp_chart_window(App *app) { + ULONGLONG minimum = (ULONGLONG)app->interval_ms * 2ULL; + ULONGLONG maximum = chart_max_window_ms(app); + if (minimum < 10000ULL) minimum = 10000ULL; + if (minimum > maximum) minimum = maximum; + if (app->chart_window_ms < minimum) app->chart_window_ms = minimum; + if (app->chart_window_ms > maximum) app->chart_window_ms = maximum; +} + +static ULONGLONG chart_max_pan_ms(const App *app) { + if (app->chart_history_count < 2) return 0; + ULONGLONG duration = chart_timestamp(app, app->chart_history_count - 1) - chart_timestamp(app, 0); + return duration > app->chart_window_ms ? duration - app->chart_window_ms : 0; +} + +static void clamp_chart_pan(App *app) { + ULONGLONG maximum = chart_max_pan_ms(app); + if (app->chart_pan_ms > maximum) app->chart_pan_ms = maximum; +} + +static void format_chart_duration(ULONGLONG milliseconds, char *buffer, size_t buffer_size) { + if (milliseconds < 1000ULL) { + snprintf(buffer, buffer_size, "%.1fs", (double)milliseconds / 1000.0); + return; + } + ULONGLONG seconds = milliseconds / 1000ULL; + if (seconds < 60ULL) { + snprintf(buffer, buffer_size, "%llus", (unsigned long long)seconds); + } else if (seconds < 3600ULL) { + snprintf(buffer, buffer_size, "%llum%02llus", + (unsigned long long)(seconds / 60ULL), + (unsigned long long)(seconds % 60ULL)); + } else { + snprintf(buffer, buffer_size, "%lluh%02llum", + (unsigned long long)(seconds / 3600ULL), + (unsigned long long)((seconds % 3600ULL) / 60ULL)); + } +} + +static void zoom_chart(App *app, bool zoom_out) { + static const ULONGLONG windows[] = { + 10000ULL, 15000ULL, 30000ULL, 60000ULL, 120000ULL, 300000ULL, + 600000ULL, 1800000ULL, 3600000ULL, 7200000ULL, 14400000ULL, + 28800000ULL, 43200000ULL, 86400000ULL + }; + clamp_chart_window(app); + ULONGLONG minimum = (ULONGLONG)app->interval_ms * 2ULL; + ULONGLONG maximum = chart_max_window_ms(app); + if (minimum < 10000ULL) minimum = 10000ULL; + if (minimum > maximum) minimum = maximum; + ULONGLONG next = zoom_out ? maximum : minimum; + + if (zoom_out) { + for (size_t i = 0; i < _countof(windows); ++i) { + if (windows[i] > app->chart_window_ms && windows[i] <= maximum) { + next = windows[i]; + break; + } + } + if (maximum <= app->chart_window_ms) next = app->chart_window_ms; + } else { + for (size_t i = 0; i < _countof(windows); ++i) { + if (windows[i] >= minimum && windows[i] < app->chart_window_ms) next = windows[i]; + if (windows[i] >= app->chart_window_ms) break; + } + if (minimum >= app->chart_window_ms) next = app->chart_window_ms; + } + app->chart_window_ms = next; + clamp_chart_pan(app); +} + +static void pan_chart(App *app, int direction, bool full_window) { + clamp_chart_window(app); + clamp_chart_pan(app); + ULONGLONG step = full_window ? app->chart_window_ms : app->chart_window_ms / 4ULL; + if (step < 1000ULL) step = 1000ULL; + ULONGLONG maximum = chart_max_pan_ms(app); + if (direction > 0) { + app->chart_pan_ms = maximum - app->chart_pan_ms < step + ? maximum : app->chart_pan_ms + step; + } else { + app->chart_pan_ms = app->chart_pan_ms < step ? 0 : app->chart_pan_ms - step; + } +} + +static bool ensure_chart_plot_capacity(App *app, size_t needed) { + if (needed <= app->chart_plot_capacity) return true; + if (needed > SIZE_MAX / sizeof(*app->chart_plot_values)) return false; + double *values = (double *)malloc(needed * sizeof(*values)); + unsigned char *present = (unsigned char *)malloc(needed * sizeof(*present)); + if (!values || !present) { + free(present); + free(values); + return false; + } + free(app->chart_plot_present); + free(app->chart_plot_values); + app->chart_plot_values = values; + app->chart_plot_present = present; + app->chart_plot_capacity = needed; + return true; +} + static void log_sample(App *app) { if (!app->log_file) return; char timestamp[64], gpu_name[256], process_name[520], engine[96]; @@ -678,12 +829,16 @@ static void render_help(TextBuffer *buffer) { " l Toggle CSV logging 1-0 Select chart metric\n" " Space Pause/resume\n" " + / - Refresh faster/slower h Help q Quit\n\n" + "%sChart navigation%s\n" + " Wheel Zoom visible time Shift+wheel Pan through history\n" + " Left/Right Pan by 1/4 view PgUp/PgDn Pan by a full view\n" + " Home/End Oldest retained/live Hover Exact point value\n\n" "%sAccounting%s\n" " Process GPU %% is the busiest Windows GPU engine for that PID, matching Task Manager's\n" " model. Dedicated/shared values come from WDDM counters. A trailing ! marks a value\n" " larger than physical VRAM, a known Windows counter anomaly rather than residency.\n" " Board telemetry comes from NVML; DXGI supplies a vendor-neutral memory fallback.\n", - ANSI_BOLD, ANSI_RESET, ANSI_BOLD, ANSI_RESET); + ANSI_BOLD, ANSI_RESET, ANSI_BOLD, ANSI_RESET, ANSI_BOLD, ANSI_RESET); } static void render_gpu_info(TextBuffer *buffer, const App *app) { @@ -842,7 +997,7 @@ static const char *chart_color(ChartMetric metric) { return ANSI_GREEN; } -static void render_chart_view(App *app, TextBuffer *buffer, int columns, int rows) { +static void render_chart_view_fallback(App *app, TextBuffer *buffer, int columns, int rows) { app->header_hit_count = 0; app->table_header_row = -1; app->table_first_process_row = -1; @@ -930,6 +1085,219 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row text_buffer_append(buffer, "%s%s%s", ANSI_DIM, clipped_controls, ANSI_RESET); } +static void render_chart_view(App *app, TextBuffer *buffer, int columns, int rows) { + app->header_hit_count = 0; + app->table_header_row = -1; + app->table_first_process_row = -1; + app->table_last_process_row = -1; + app->chart_plot_left = -1; + app->chart_plot_right = -1; + app->chart_plot_top = -1; + app->chart_plot_bottom = -1; + if (columns < 24 || rows < 8) { + app->chart_hover_active = false; + text_buffer_append(buffer, "%sVGPU-Mon%s\nResize to at least 24 x 8 for chart view.\n", + ANSI_BOLD, ANSI_RESET); + return; + } + + clamp_chart_window(app); + clamp_chart_pan(app); + ChartMetric metric = app->chart_metric; + const char *unit = chart_metric_unit(metric); + bool available = chart_metric_available(app, metric); + double current = current_chart_value(app, metric); + double observed_max = 0.0; + size_t count = app->chart_history_count; + ULONGLONG newest_time = count ? chart_timestamp(app, count - 1) : GetTickCount64(); + ULONGLONG view_end = newest_time >= app->chart_pan_ms + ? newest_time - app->chart_pan_ms : 0; + ULONGLONG view_start = view_end >= app->chart_window_ms + ? view_end - app->chart_window_ms : 0; + + const int prefix_width = 8; + int plot_width = columns - prefix_width; + int chart_height = rows - 6; + if (!ensure_chart_plot_capacity(app, (size_t)plot_width)) { + render_chart_view_fallback(app, buffer, columns, rows); + return; + } + double *plot_values = app->chart_plot_values; + unsigned char *plot_present = app->chart_plot_present; + memset(plot_values, 0, (size_t)plot_width * sizeof(*plot_values)); + memset(plot_present, 0, (size_t)plot_width * sizeof(*plot_present)); + + size_t first_visible = count; + size_t last_visible = count; + for (size_t i = 0; i < count; ++i) { + ULONGLONG timestamp = chart_timestamp(app, i); + if (timestamp < view_start || timestamp > view_end) continue; + if (first_visible == count) first_visible = i; + last_visible = i + 1; + double value = chart_value(app, metric, i); + if (value > observed_max) observed_max = value; + ULONGLONG offset = timestamp - view_start; + int x = (int)((offset * (ULONGLONG)(plot_width - 1)) / app->chart_window_ms); + if (x < 0) x = 0; + if (x >= plot_width) x = plot_width - 1; + if (!plot_present[x] || value > plot_values[x]) plot_values[x] = value; + plot_present[x] = 1; + } + + if (first_visible < count) { + size_t cursor = first_visible; + bool have_value = false; + double held_value = 0.0; + for (int x = 0; x < plot_width; ++x) { + ULONGLONG target = view_start + + (app->chart_window_ms * (ULONGLONG)x) / (ULONGLONG)(plot_width - 1); + while (cursor < last_visible && chart_timestamp(app, cursor) <= target) { + held_value = chart_value(app, metric, cursor); + have_value = true; + cursor++; + } + if (!plot_present[x] && have_value) { + plot_values[x] = held_value; + plot_present[x] = 1; + } + } + } + + double scale = 100.0; + if (metric == CHART_POWER) { + scale = app->telemetry.power_limit_w > 0 ? app->telemetry.power_limit_w : 100.0; + if (observed_max > scale) scale = ceil(observed_max * 1.10 / 10.0) * 10.0; + } else if (metric == CHART_TEMPERATURE && observed_max > scale) { + scale = ceil(observed_max * 1.10 / 10.0) * 10.0; + } + + char title[256], title_display[256], gpu_name[256], status[256], status_display[256]; + char span[32], retained[32], edge[48]; + format_chart_duration(app->chart_window_ms, span, sizeof(span)); + ULONGLONG retained_ms = count > 1 ? newest_time - chart_timestamp(app, 0) : 0; + format_chart_duration(retained_ms, retained, sizeof(retained)); + if (app->chart_pan_ms == 0) { + snprintf(edge, sizeof(edge), "LIVE"); + } else { + char age[32]; + format_chart_duration(app->chart_pan_ms, age, sizeof(age)); + snprintf(edge, sizeof(edge), "%s ago", age); + } + snprintf(title, sizeof(title), "VGPU-Mon %s | %s%s%s", + VGPU_VERSION, chart_metric_name(metric), app->paused ? " | PAUSED" : "", + app->log_file ? " | REC" : ""); + truncate_text(title, title_display, sizeof(title_display), (size_t)columns); + truncate_text(app->telemetry.name, gpu_name, sizeof(gpu_name), (size_t)columns); + if (available) { + snprintf(status, sizeof(status), + "Now %.1f%s | View peak %.1f%s | Span %s | %s | Retained %s", + current, unit, observed_max, unit, span, edge, retained); + } else { + snprintf(status, sizeof(status), + "Unavailable from this GPU, driver, or Windows driver mode | Span %s | %s", + span, edge); + } + truncate_text(status, status_display, sizeof(status_display), (size_t)columns); + text_buffer_append(buffer, "%s%s%s\n%s%s%s\n%s\n", + ANSI_BOLD, title_display, ANSI_RESET, + ANSI_CYAN, gpu_name, ANSI_RESET, status_display); + + app->chart_plot_left = prefix_width; + app->chart_plot_right = prefix_width + plot_width - 1; + app->chart_plot_top = 3; + app->chart_plot_bottom = 3 + chart_height - 1; + + int hover_plot_x = -1; + int tooltip_row = -1; + int tooltip_start = -1; + char tooltip[96] = ""; + char tooltip_display[96] = ""; + if (app->chart_hover_active && + app->chart_hover_x >= app->chart_plot_left && + app->chart_hover_x <= app->chart_plot_right && + app->chart_hover_y >= app->chart_plot_top && + app->chart_hover_y <= app->chart_plot_bottom) { + hover_plot_x = app->chart_hover_x - app->chart_plot_left; + tooltip_row = app->chart_hover_y - app->chart_plot_top; + ULONGLONG target = view_start + + (app->chart_window_ms * (ULONGLONG)hover_plot_x) / + (ULONGLONG)(plot_width - 1); + size_t nearest = count; + ULONGLONG nearest_distance = ~(ULONGLONG)0; + if (first_visible < count && target >= chart_timestamp(app, first_visible)) { + for (size_t i = first_visible; i < last_visible; ++i) { + ULONGLONG timestamp = chart_timestamp(app, i); + ULONGLONG distance = timestamp > target + ? timestamp - target : target - timestamp; + if (distance < nearest_distance) { + nearest = i; + nearest_distance = distance; + } + } + } + if (nearest < count) { + ULONGLONG age_ms = newest_time - chart_timestamp(app, nearest); + if (age_ms < 1000ULL) { + snprintf(tooltip, sizeof(tooltip), " Point %.1f%s | now ", + chart_value(app, metric, nearest), unit); + } else { + char age[32]; + format_chart_duration(age_ms, age, sizeof(age)); + snprintf(tooltip, sizeof(tooltip), " Point %.1f%s | %s ago ", + chart_value(app, metric, nearest), unit, age); + } + } else { + ULONGLONG age_ms = newest_time > target ? newest_time - target : 0; + char age[32]; + format_chart_duration(age_ms, age, sizeof(age)); + snprintf(tooltip, sizeof(tooltip), " No sample | %s ago ", age); + } + truncate_text(tooltip, tooltip_display, sizeof(tooltip_display), (size_t)plot_width); + int tooltip_length = (int)strlen(tooltip_display); + tooltip_start = hover_plot_x + 2; + if (tooltip_start + tooltip_length > plot_width) + tooltip_start = hover_plot_x - tooltip_length - 1; + if (tooltip_start < 0) tooltip_start = 0; + if (tooltip_start + tooltip_length > plot_width) + tooltip_start = plot_width - tooltip_length; + } + + const char *color = chart_color(metric); + for (int row = 0; row < chart_height; ++row) { + double upper = scale * (double)(chart_height - row) / (double)chart_height; + double lower = scale * (double)(chart_height - row - 1) / (double)chart_height; + bool labeled = row == 0 || row == chart_height - 1 || row == chart_height / 2; + if (labeled) text_buffer_append(buffer, "%6.1f%s|", upper, unit); + else text_buffer_append(buffer, " |"); + text_buffer_append(buffer, "%s", color); + for (int x = 0; x < plot_width; ++x) { + if (row == tooltip_row && x == tooltip_start && tooltip_display[0]) { + text_buffer_append(buffer, "%s%s%s%s", ANSI_RESET, ANSI_REVERSE, + tooltip_display, ANSI_RESET); + text_buffer_append(buffer, "%s", color); + x += (int)strlen(tooltip_display) - 1; + continue; + } + const char *cell = (row == chart_height / 2) ? "-" : " "; + if (plot_present[x]) { + if (plot_values[x] >= upper) cell = "\xE2\x96\x88"; + else if (plot_values[x] > lower) cell = "\xE2\x96\x84"; + } + if (x == hover_plot_x) text_buffer_append(buffer, "%s", ANSI_REVERSE); + text_buffer_append(buffer, "%s", cell); + if (x == hover_plot_x) text_buffer_append(buffer, "%s%s", ANSI_RESET, color); + } + text_buffer_append(buffer, "%s\n", ANSI_RESET); + } + + const char *controls = columns >= 112 + ? "Wheel zoom | Shift+wheel / Left/Right pan | Home oldest | End live | Hover exact | 1-0 metric | c table | q quit" + : "Wheel zoom | Left/Right pan | End live | Hover exact value | c table | q quit"; + char clipped_controls[256]; + truncate_text(controls, clipped_controls, sizeof(clipped_controls), (size_t)columns); + text_buffer_append(buffer, "%s%s%s", ANSI_DIM, clipped_controls, ANSI_RESET); +} + static void render_app(App *app, TextBuffer *buffer) { int columns, rows; terminal_geometry(&columns, &rows, &app->viewport_left, &app->viewport_top); @@ -1114,6 +1482,8 @@ static void select_gpu(App *app, int delta) { app->history_next = 0; app->chart_history_count = 0; app->chart_history_next = 0; + app->chart_pan_ms = 0; + app->chart_hover_active = false; sample_app(app); } @@ -1164,7 +1534,10 @@ static bool handle_key(App *app, int key) { case 'q': case 'Q': return false; case 'h': case 'H': app->show_help = !app->show_help; break; case 'i': case 'I': app->show_gpu_info = !app->show_gpu_info; break; - case 'c': case 'C': app->chart_view = !app->chart_view; break; + case 'c': case 'C': + app->chart_view = !app->chart_view; + if (!app->chart_view) app->chart_hover_active = false; + break; case 'd': case 'D': app->show_details = !app->show_details; break; case 'u': case 'U': set_sort(app, SORT_GPU); break; case 'm': case 'M': set_sort(app, SORT_DEDICATED); break; @@ -1200,10 +1573,14 @@ static bool handle_key(App *app, int key) { case '+': case '=': if (app->interval_ms > 250) app->interval_ms -= 250; if (app->interval_ms < 250) app->interval_ms = 250; + clamp_chart_window(app); + clamp_chart_pan(app); break; case '-': case '_': if (app->interval_ms < 5000) app->interval_ms += 250; if (app->interval_ms > 5000) app->interval_ms = 5000; + clamp_chart_window(app); + clamp_chart_pan(app); break; } return true; @@ -1212,6 +1589,29 @@ static bool handle_key(App *app, int key) { static bool handle_console_key(App *app, const KEY_EVENT_RECORD *event) { if (!event->bKeyDown) return true; int repeat = event->wRepeatCount > 0 ? event->wRepeatCount : 1; + if (app->chart_view && !app->editing_filter && !app->confirm_pid) { + switch (event->wVirtualKeyCode) { + case VK_LEFT: + for (int i = 0; i < repeat; ++i) pan_chart(app, 1, false); + return true; + case VK_RIGHT: + for (int i = 0; i < repeat; ++i) pan_chart(app, -1, false); + return true; + case VK_PRIOR: + for (int i = 0; i < repeat; ++i) pan_chart(app, 1, true); + return true; + case VK_NEXT: + for (int i = 0; i < repeat; ++i) pan_chart(app, -1, true); + return true; + case VK_HOME: + clamp_chart_window(app); + app->chart_pan_ms = chart_max_pan_ms(app); + return true; + case VK_END: + app->chart_pan_ms = 0; + return true; + } + } switch (event->wVirtualKeyCode) { case VK_UP: select_relative(app, -repeat); return true; case VK_DOWN: select_relative(app, repeat); return true; @@ -1230,16 +1630,38 @@ static bool handle_console_key(App *app, const KEY_EVENT_RECORD *event) { } static void handle_console_mouse(App *app, const MOUSE_EVENT_RECORD *event) { + int x = event->dwMousePosition.X - app->viewport_left; + int y = event->dwMousePosition.Y - app->viewport_top; + bool in_chart = app->chart_view && + x >= app->chart_plot_left && x <= app->chart_plot_right && + y >= app->chart_plot_top && y <= app->chart_plot_bottom; + + if (app->chart_view && event->dwEventFlags == MOUSE_MOVED) { + app->chart_hover_active = in_chart; + if (in_chart) { + app->chart_hover_x = x; + app->chart_hover_y = y; + } + return; + } if (event->dwEventFlags == MOUSE_WHEELED) { SHORT delta = (SHORT)HIWORD(event->dwButtonState); + if (in_chart) { + app->chart_hover_active = true; + app->chart_hover_x = x; + app->chart_hover_y = y; + if (event->dwControlKeyState & SHIFT_PRESSED) + pan_chart(app, delta > 0 ? 1 : -1, false); + else + zoom_chart(app, delta < 0); + return; + } select_relative(app, delta > 0 ? -3 : 3); return; } if (event->dwEventFlags != 0 || !(event->dwButtonState & FROM_LEFT_1ST_BUTTON_PRESSED) || app->chart_view) return; - int x = event->dwMousePosition.X - app->viewport_left; - int y = event->dwMousePosition.Y - app->viewport_top; if (y == app->table_header_row) { for (size_t i = 0; i < app->header_hit_count; ++i) { HeaderHit *hit = &app->header_hits[i]; @@ -1415,7 +1837,8 @@ static void print_help(void) { " --help Show this help\n" " --version Show the version\n\n" "Run without options in Windows Terminal for the interactive UI.\n" - "Headers are mouse-clickable; click again to reverse the sort.\n", + "Headers are mouse-clickable; click again to reverse the sort.\n" + "Charts support wheel zoom, Shift+wheel or arrow-key panning, and exact hover values.\n", VGPU_VERSION); } @@ -1435,6 +1858,11 @@ static bool initialize_app(App *app) { static void cleanup_app(App *app) { stop_logging(app); + free(app->chart_plot_present); + free(app->chart_plot_values); + app->chart_plot_present = NULL; + app->chart_plot_values = NULL; + app->chart_plot_capacity = 0; pdh_gpu_close(&app->pdh); dxgi_close(&app->dxgi); nvml_close(&app->nvml); @@ -1465,6 +1893,7 @@ static int app_main(int argc, char **argv) { app->sort_mode = SORT_GPU; app->sort_descending = true; app->chart_metric = CHART_GPU; + app->chart_window_ms = VGPU_CHART_DEFAULT_WINDOW_MS; bool once = false; bool json = false; const char *initial_log = NULL; diff --git a/src/version.h b/src/version.h index d096174..395d02c 100644 --- a/src/version.h +++ b/src/version.h @@ -1,7 +1,7 @@ #ifndef VGPU_VERSION_H #define VGPU_VERSION_H -#define VGPU_VERSION "1.2.0" -#define VGPU_VERSION_NUM 1,2,0,0 +#define VGPU_VERSION "1.3.0" +#define VGPU_VERSION_NUM 1,3,0,0 #endif diff --git a/src/vgpu.h b/src/vgpu.h index 4e456ce..1e6bfe9 100644 --- a/src/vgpu.h +++ b/src/vgpu.h @@ -12,7 +12,8 @@ #define VGPU_MAX_GPUS 16 #define VGPU_MAX_PROCESSES 4096 #define VGPU_HISTORY_SIZE 72 -#define VGPU_CHART_HISTORY_SIZE 512 +#define VGPU_CHART_HISTORY_SIZE 14400 +#define VGPU_CHART_DEFAULT_WINDOW_MS 60000ULL typedef struct { unsigned int index; diff --git a/tests/test_conpty.c b/tests/test_conpty.c index 06f1ca0..4506116 100644 --- a/tests/test_conpty.c +++ b/tests/test_conpty.c @@ -239,8 +239,8 @@ int main(int argc, char **argv) { return 1; } - /* Exercise chart switching, mouse sorting, and both shrink and grow paths - before requesting the application's normal quit path. */ + /* Exercise chart switching, mouse sorting, chart zoom/hover input, and + both shrink and grow paths before requesting the normal quit path. */ Sleep(700); DWORD written = 0; WriteFile(input_write, "c", 1, &written, NULL); @@ -248,12 +248,23 @@ int main(int argc, char **argv) { const char mouse_click[] = "\x1b[<0;72;11M\x1b[<0;72;11m"; WriteFile(input_write, mouse_click, (DWORD)(sizeof(mouse_click) - 1), &written, NULL); Sleep(300); + WriteFile(input_write, "c", 1, &written, NULL); + Sleep(300); COORD small = {60, 12}; HRESULT resize_small = ResizePseudoConsole(pseudo_console, small); Sleep(500); COORD large = {120, 30}; HRESULT resize_large = ResizePseudoConsole(pseudo_console, large); Sleep(500); + const char chart_wheel[] = "\x1b[<65;90;15M"; + WriteFile(input_write, chart_wheel, (DWORD)(sizeof(chart_wheel) - 1), &written, NULL); + Sleep(300); + const char chart_pan[] = "\x1b[<68;90;15M"; + WriteFile(input_write, chart_pan, (DWORD)(sizeof(chart_pan) - 1), &written, NULL); + Sleep(300); + const char chart_hover[] = "\x1b[<35;119;14M"; + WriteFile(input_write, chart_hover, (DWORD)(sizeof(chart_hover) - 1), &written, NULL); + Sleep(400); WriteFile(input_write, "q", 1, &written, NULL); DWORD wait = WaitForSingleObject(process.hProcess, 5000); @@ -285,18 +296,21 @@ int main(int argc, char **argv) { bool left_alternate = strstr(capture.data, "\x1b[?1049l") != NULL; bool rendered_chart = strstr(capture.data, "Allocated VRAM") != NULL; bool clicked_sort = strstr(capture.data, "GPU ^") != NULL; + bool zoomed_chart = strstr(capture.data, "Span 2m00s") != NULL; + bool rendered_hover = strstr(capture.data, "Point ") != NULL; bool no_debug_leaks = strstr(capture.data, "Detected memory leaks!") == NULL; bool passed = SUCCEEDED(resize_small) && SUCCEEDED(resize_large) && wait == WAIT_OBJECT_0 && exit_code == 0 && clears >= 1 && line_erases >= 20 && max_frame_lines <= 29 && max_frame_columns <= 120 && entered_alternate && left_alternate && rendered_chart && clicked_sort && - no_debug_leaks && !capture.overflow; + zoomed_chart && rendered_hover && no_debug_leaks && !capture.overflow; if (!passed) { fprintf(stderr, - "ConPTY test failed: exit=%lu wait=%lu clears=%zu line-erases=%zu max-frame-newlines=%zu max-frame-columns=%zu enter=%d leave=%d chart=%d mouse-sort=%d no-leaks=%d overflow=%d resize=0x%08lx/0x%08lx bytes=%zu\n", + "ConPTY test failed: exit=%lu wait=%lu clears=%zu line-erases=%zu max-frame-newlines=%zu max-frame-columns=%zu enter=%d leave=%d chart=%d mouse-sort=%d zoom=%d hover=%d no-leaks=%d overflow=%d resize=0x%08lx/0x%08lx bytes=%zu\n", (unsigned long)exit_code, (unsigned long)wait, clears, line_erases, max_frame_lines, max_frame_columns, - entered_alternate, left_alternate, rendered_chart, clicked_sort, no_debug_leaks, capture.overflow, + entered_alternate, left_alternate, rendered_chart, clicked_sort, + zoomed_chart, rendered_hover, no_debug_leaks, capture.overflow, (unsigned long)resize_small, (unsigned long)resize_large, capture.length); fputs("--- captured pseudoconsole output ---\n", stderr); fwrite(capture.data, 1, capture.length, stderr); From 6fd74f73a212618b29bb643883eb84dea5ad48ef Mon Sep 17 00:00:00 2001 From: Seth <80608102+xptea@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:05:29 -0400 Subject: [PATCH 2/2] Address chart renderer review feedback --- src/main.c | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/main.c b/src/main.c index 1a5f78f..983a978 100644 --- a/src/main.c +++ b/src/main.c @@ -1103,6 +1103,8 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row clamp_chart_window(app); clamp_chart_pan(app); + /* Define the visible range with monotonic sample timestamps so changing + the refresh interval cannot distort older data. */ ChartMetric metric = app->chart_metric; const char *unit = chart_metric_unit(metric); bool available = chart_metric_available(app, metric); @@ -1118,6 +1120,7 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row const int prefix_width = 8; int plot_width = columns - prefix_width; int chart_height = rows - 6; + /* Scratch columns are retained across frames and only grow on resize. */ if (!ensure_chart_plot_capacity(app, (size_t)plot_width)) { render_chart_view_fallback(app, buffer, columns, rows); return; @@ -1129,6 +1132,7 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row size_t first_visible = count; size_t last_visible = count; + /* Bucket real samples into terminal columns while preserving peaks. */ for (size_t i = 0; i < count; ++i) { ULONGLONG timestamp = chart_timestamp(app, i); if (timestamp < view_start || timestamp > view_end) continue; @@ -1145,6 +1149,8 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row } if (first_visible < count) { + /* Carry the last sample through empty columns to form a continuous + step chart without fabricating samples before history begins. */ size_t cursor = first_visible; bool have_value = false; double held_value = 0.0; @@ -1164,6 +1170,8 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row } double scale = 100.0; + /* Percent metrics use a fixed scale; temperature and power expand only + when the visible data exceeds their normal ceiling. */ if (metric == CHART_POWER) { scale = app->telemetry.power_limit_w > 0 ? app->telemetry.power_limit_w : 100.0; if (observed_max > scale) scale = ceil(observed_max * 1.10 / 10.0) * 10.0; @@ -1207,9 +1215,12 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row app->chart_plot_top = 3; app->chart_plot_bottom = 3 + chart_height - 1; + /* Resolve the pointer's time coordinate to the nearest stored sample and + prepare an ASCII tooltip that can be overlaid without changing width. */ int hover_plot_x = -1; int tooltip_row = -1; int tooltip_start = -1; + int tooltip_length = 0; char tooltip[96] = ""; char tooltip_display[96] = ""; if (app->chart_hover_active && @@ -1253,7 +1264,7 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row snprintf(tooltip, sizeof(tooltip), " No sample | %s ago ", age); } truncate_text(tooltip, tooltip_display, sizeof(tooltip_display), (size_t)plot_width); - int tooltip_length = (int)strlen(tooltip_display); + tooltip_length = (int)strlen(tooltip_display); tooltip_start = hover_plot_x + 2; if (tooltip_start + tooltip_length > plot_width) tooltip_start = hover_plot_x - tooltip_length - 1; @@ -1263,6 +1274,8 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row } const char *color = chart_color(metric); + /* Draw one bounded terminal row at a time. Tooltip-covered iterations + emit no cell because the first iteration emitted the complete overlay. */ for (int row = 0; row < chart_height; ++row) { double upper = scale * (double)(chart_height - row) / (double)chart_height; double lower = scale * (double)(chart_height - row - 1) / (double)chart_height; @@ -1271,11 +1284,13 @@ static void render_chart_view(App *app, TextBuffer *buffer, int columns, int row else text_buffer_append(buffer, " |"); text_buffer_append(buffer, "%s", color); for (int x = 0; x < plot_width; ++x) { - if (row == tooltip_row && x == tooltip_start && tooltip_display[0]) { - text_buffer_append(buffer, "%s%s%s%s", ANSI_RESET, ANSI_REVERSE, - tooltip_display, ANSI_RESET); - text_buffer_append(buffer, "%s", color); - x += (int)strlen(tooltip_display) - 1; + if (row == tooltip_row && x >= tooltip_start && + x < tooltip_start + tooltip_length) { + if (x == tooltip_start) { + text_buffer_append(buffer, "%s%s%s%s", ANSI_RESET, ANSI_REVERSE, + tooltip_display, ANSI_RESET); + text_buffer_append(buffer, "%s", color); + } continue; } const char *cell = (row == chart_height / 2) ? "-" : " ";