sgtop 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sgtop-0.1.0/LICENSE +21 -0
- sgtop-0.1.0/PKG-INFO +97 -0
- sgtop-0.1.0/README.md +77 -0
- sgtop-0.1.0/pyproject.toml +33 -0
- sgtop-0.1.0/setup.cfg +4 -0
- sgtop-0.1.0/src/sgtop/__init__.py +1 -0
- sgtop-0.1.0/src/sgtop/app.py +219 -0
- sgtop-0.1.0/src/sgtop/cli.py +28 -0
- sgtop-0.1.0/src/sgtop/data.py +50 -0
- sgtop-0.1.0/src/sgtop/server.py +620 -0
- sgtop-0.1.0/src/sgtop/theme.py +128 -0
- sgtop-0.1.0/src/sgtop.egg-info/PKG-INFO +97 -0
- sgtop-0.1.0/src/sgtop.egg-info/SOURCES.txt +14 -0
- sgtop-0.1.0/src/sgtop.egg-info/dependency_links.txt +1 -0
- sgtop-0.1.0/src/sgtop.egg-info/entry_points.txt +3 -0
- sgtop-0.1.0/src/sgtop.egg-info/top_level.txt +1 -0
sgtop-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HarrytheOrange
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sgtop-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sgtop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: btop-style terminal monitor for a live SGLang deployment
|
|
5
|
+
Author: HarrytheOrange
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/HarrytheOrange/sgtop
|
|
8
|
+
Project-URL: Issues, https://github.com/HarrytheOrange/sgtop/issues
|
|
9
|
+
Keywords: sglang,llm,monitoring,tui,gpu,prometheus
|
|
10
|
+
Classifier: Environment :: Console :: Curses
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: System :: Monitoring
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# sgtop
|
|
22
|
+
|
|
23
|
+
A [btop](https://github.com/aristocratos/btop)-style terminal dashboard for a live [SGLang](https://github.com/sgl-project/sglang) deployment — concurrency, KV-cache headroom, GPU utilization, and (optionally) per-request latency, all in one screen.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
Not affiliated with the SGLang project. It's a small, independent, read-only sidecar: it never touches your inference traffic, it only reads sglang's own log file, `nvidia-smi`, and (if enabled) sglang's Prometheus `/metrics` endpoint.
|
|
28
|
+
|
|
29
|
+
## Why
|
|
30
|
+
|
|
31
|
+
`nvtop` tells you a GPU is at 98% utilization. It doesn't tell you whether your sglang server can actually take more concurrent requests, whether one data-parallel replica is starving while another is idle, or what your real TTFT looks like. `sgtop` answers those instead:
|
|
32
|
+
|
|
33
|
+
- **Concurrency vs. capacity** — running requests / the actual `max_running_requests` sglang computed at startup, not a guess.
|
|
34
|
+
- **KV-cache headroom** — tokens in use / total KV budget, per replica.
|
|
35
|
+
- **Per-DP-replica breakdown** — if you run `--dp-size > 1`, each replica gets its own panel, so an imbalanced load-balancing setup is obvious at a glance instead of hidden in an aggregate number.
|
|
36
|
+
- **Latency percentiles** — TTFT / end-to-end / queue-time p50/p90/p99 over a rolling 5-minute window, once you turn on `--enable-metrics` on the sglang side.
|
|
37
|
+
- **GPU panel** — utilization, memory, temperature, power, right below the request-level view.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pipx install sgtop # recommended — isolated, puts `sgtop` on your PATH
|
|
43
|
+
# or
|
|
44
|
+
pip install --user sgtop
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Requires Python ≥ 3.9 on Linux or macOS. No dependencies — it's pure standard library (`curses` + `urllib`), so there's nothing else to install.
|
|
48
|
+
|
|
49
|
+
From source:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/HarrytheOrange/sgtop
|
|
53
|
+
cd sgtop
|
|
54
|
+
pip install -e .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
`sgtop` is a client. It needs something to talk to: `sgtop-server`, a small sidecar that tails sglang's log file and exposes a JSON API. Run it on the same machine as sglang (or anywhere with network access to it):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# 1. Launch sglang, redirecting its output to a file sgtop-server can tail:
|
|
63
|
+
python -m sglang.launch_server --model-path ... --dp-size 2 --enable-metrics > server.log 2>&1 &
|
|
64
|
+
|
|
65
|
+
# 2. Start the sidecar, pointing it at that log file:
|
|
66
|
+
sgtop-server --log server.log --service-port 30000 --port 30001 &
|
|
67
|
+
|
|
68
|
+
# 3. Watch it:
|
|
69
|
+
sgtop --port 30001
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`sgtop-server` also serves a plain HTML version of the same data at `http://<host>:30001/` — handy if you want to glance at it from a browser instead of a terminal, or point it at a machine other than the one you're `ssh`'d into:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
sgtop --host 10.0.0.5 --port 30001
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## `--enable-metrics` (optional, recommended)
|
|
79
|
+
|
|
80
|
+
Without it, `sgtop` still shows concurrency, KV-cache usage, throughput and GPU stats — all parsed from sglang's own log lines, no extra flags needed.
|
|
81
|
+
|
|
82
|
+
Add `--enable-metrics` to your `launch_server` command and the LATENCY panel comes alive with TTFT, end-to-end latency, per-replica queue time, and prefix-cache hit rate, scraped from sglang's Prometheus endpoint. `sgtop-server` diffs consecutive scrapes itself to give you a real rolling-window p50/p90/p99 instead of an all-time average — Prometheus histograms are cumulative counters, so a naive read would just show a number that keeps drifting toward whatever the very first few requests looked like.
|
|
83
|
+
|
|
84
|
+
## Keybindings
|
|
85
|
+
|
|
86
|
+
`q` to quit. That's it — it's a dashboard, not an editor.
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
- `sgtop-server` tails the log file sglang writes when you redirect its stdout/stderr, extracting the periodic `Decode batch` / `Prefill batch` lines each DP replica prints, plus the one-time `max_total_num_tokens=... max_running_requests=...` line printed at startup. It samples `nvidia-smi` on an interval, and if `--enable-metrics` is on, scrapes `/metrics` and keeps a short rolling window of histogram deltas for latency percentiles. All of it is exposed as JSON at `/api/status`.
|
|
91
|
+
- `sgtop` polls that JSON and renders it with `curses` — gradient meters, sparkline history, boxed panels, degrading gracefully to a 3-color/16-color palette on terminals without 256-color support.
|
|
92
|
+
|
|
93
|
+
Both are plain Python, no compiled extensions, so `pipx install` is instant.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT — see [LICENSE](LICENSE).
|
sgtop-0.1.0/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# sgtop
|
|
2
|
+
|
|
3
|
+
A [btop](https://github.com/aristocratos/btop)-style terminal dashboard for a live [SGLang](https://github.com/sgl-project/sglang) deployment — concurrency, KV-cache headroom, GPU utilization, and (optionally) per-request latency, all in one screen.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Not affiliated with the SGLang project. It's a small, independent, read-only sidecar: it never touches your inference traffic, it only reads sglang's own log file, `nvidia-smi`, and (if enabled) sglang's Prometheus `/metrics` endpoint.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
`nvtop` tells you a GPU is at 98% utilization. It doesn't tell you whether your sglang server can actually take more concurrent requests, whether one data-parallel replica is starving while another is idle, or what your real TTFT looks like. `sgtop` answers those instead:
|
|
12
|
+
|
|
13
|
+
- **Concurrency vs. capacity** — running requests / the actual `max_running_requests` sglang computed at startup, not a guess.
|
|
14
|
+
- **KV-cache headroom** — tokens in use / total KV budget, per replica.
|
|
15
|
+
- **Per-DP-replica breakdown** — if you run `--dp-size > 1`, each replica gets its own panel, so an imbalanced load-balancing setup is obvious at a glance instead of hidden in an aggregate number.
|
|
16
|
+
- **Latency percentiles** — TTFT / end-to-end / queue-time p50/p90/p99 over a rolling 5-minute window, once you turn on `--enable-metrics` on the sglang side.
|
|
17
|
+
- **GPU panel** — utilization, memory, temperature, power, right below the request-level view.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pipx install sgtop # recommended — isolated, puts `sgtop` on your PATH
|
|
23
|
+
# or
|
|
24
|
+
pip install --user sgtop
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Python ≥ 3.9 on Linux or macOS. No dependencies — it's pure standard library (`curses` + `urllib`), so there's nothing else to install.
|
|
28
|
+
|
|
29
|
+
From source:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/HarrytheOrange/sgtop
|
|
33
|
+
cd sgtop
|
|
34
|
+
pip install -e .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
`sgtop` is a client. It needs something to talk to: `sgtop-server`, a small sidecar that tails sglang's log file and exposes a JSON API. Run it on the same machine as sglang (or anywhere with network access to it):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# 1. Launch sglang, redirecting its output to a file sgtop-server can tail:
|
|
43
|
+
python -m sglang.launch_server --model-path ... --dp-size 2 --enable-metrics > server.log 2>&1 &
|
|
44
|
+
|
|
45
|
+
# 2. Start the sidecar, pointing it at that log file:
|
|
46
|
+
sgtop-server --log server.log --service-port 30000 --port 30001 &
|
|
47
|
+
|
|
48
|
+
# 3. Watch it:
|
|
49
|
+
sgtop --port 30001
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`sgtop-server` also serves a plain HTML version of the same data at `http://<host>:30001/` — handy if you want to glance at it from a browser instead of a terminal, or point it at a machine other than the one you're `ssh`'d into:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
sgtop --host 10.0.0.5 --port 30001
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## `--enable-metrics` (optional, recommended)
|
|
59
|
+
|
|
60
|
+
Without it, `sgtop` still shows concurrency, KV-cache usage, throughput and GPU stats — all parsed from sglang's own log lines, no extra flags needed.
|
|
61
|
+
|
|
62
|
+
Add `--enable-metrics` to your `launch_server` command and the LATENCY panel comes alive with TTFT, end-to-end latency, per-replica queue time, and prefix-cache hit rate, scraped from sglang's Prometheus endpoint. `sgtop-server` diffs consecutive scrapes itself to give you a real rolling-window p50/p90/p99 instead of an all-time average — Prometheus histograms are cumulative counters, so a naive read would just show a number that keeps drifting toward whatever the very first few requests looked like.
|
|
63
|
+
|
|
64
|
+
## Keybindings
|
|
65
|
+
|
|
66
|
+
`q` to quit. That's it — it's a dashboard, not an editor.
|
|
67
|
+
|
|
68
|
+
## How it works
|
|
69
|
+
|
|
70
|
+
- `sgtop-server` tails the log file sglang writes when you redirect its stdout/stderr, extracting the periodic `Decode batch` / `Prefill batch` lines each DP replica prints, plus the one-time `max_total_num_tokens=... max_running_requests=...` line printed at startup. It samples `nvidia-smi` on an interval, and if `--enable-metrics` is on, scrapes `/metrics` and keeps a short rolling window of histogram deltas for latency percentiles. All of it is exposed as JSON at `/api/status`.
|
|
71
|
+
- `sgtop` polls that JSON and renders it with `curses` — gradient meters, sparkline history, boxed panels, degrading gracefully to a 3-color/16-color palette on terminals without 256-color support.
|
|
72
|
+
|
|
73
|
+
Both are plain Python, no compiled extensions, so `pipx install` is instant.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sgtop"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "btop-style terminal monitor for a live SGLang deployment"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
authors = [{ name = "HarrytheOrange" }]
|
|
14
|
+
keywords = ["sglang", "llm", "monitoring", "tui", "gpu", "prometheus"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Environment :: Console :: Curses",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: System :: Monitoring",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/HarrytheOrange/sgtop"
|
|
26
|
+
Issues = "https://github.com/HarrytheOrange/sgtop/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
sgtop = "sgtop.cli:main"
|
|
30
|
+
sgtop-server = "sgtop.server:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
sgtop-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import curses
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from . import theme as th
|
|
8
|
+
from .data import bucket_series, fetch_status
|
|
9
|
+
|
|
10
|
+
SPARK_WIDTH = 40
|
|
11
|
+
WINDOW_S = 300 # matches the dashboard's own history retention
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def human_uptime(seconds: float | None) -> str:
|
|
15
|
+
if not seconds:
|
|
16
|
+
return "—"
|
|
17
|
+
seconds = int(seconds)
|
|
18
|
+
h, rem = divmod(seconds, 3600)
|
|
19
|
+
m, _ = divmod(rem, 60)
|
|
20
|
+
d, h = divmod(h, 24)
|
|
21
|
+
return f"{d}d {h}h {m}m" if d else f"{h}h {m}m"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fmt_secs(value: float | None) -> str:
|
|
25
|
+
if value is None:
|
|
26
|
+
return " n/a"
|
|
27
|
+
return f"{value*1000:4.0f}ms" if value < 1 else f"{value:5.2f}s"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class App:
|
|
31
|
+
def __init__(self, stdscr, host: str, port: int, interval: float):
|
|
32
|
+
self.stdscr = stdscr
|
|
33
|
+
self.host = host
|
|
34
|
+
self.port = port
|
|
35
|
+
self.interval = interval
|
|
36
|
+
self.theme = th.Theme()
|
|
37
|
+
self.last_status: dict | None = None
|
|
38
|
+
|
|
39
|
+
def run(self) -> None:
|
|
40
|
+
curses.curs_set(0)
|
|
41
|
+
self.theme.setup()
|
|
42
|
+
self.stdscr.nodelay(True)
|
|
43
|
+
self.stdscr.timeout(int(self.interval * 1000))
|
|
44
|
+
while True:
|
|
45
|
+
key = self.stdscr.getch()
|
|
46
|
+
if key in (ord("q"), 27):
|
|
47
|
+
return
|
|
48
|
+
status = fetch_status(self.host, self.port)
|
|
49
|
+
if status is not None:
|
|
50
|
+
self.last_status = status
|
|
51
|
+
self.render(self.last_status)
|
|
52
|
+
|
|
53
|
+
# -- rendering -----------------------------------------------------
|
|
54
|
+
def render(self, status: dict | None) -> None:
|
|
55
|
+
stdscr = self.stdscr
|
|
56
|
+
stdscr.erase()
|
|
57
|
+
h, w = stdscr.getmaxyx()
|
|
58
|
+
boxw = max(50, min(w - 2, 110))
|
|
59
|
+
|
|
60
|
+
self._header(0, boxw, status)
|
|
61
|
+
|
|
62
|
+
if status is None or not status.get("service_up"):
|
|
63
|
+
th.safe_addstr(stdscr, 3, 2, "waiting for sglang-dashboard at "
|
|
64
|
+
f"{self.host}:{self.port} ...", curses.color_pair(th.PAIR_BAD))
|
|
65
|
+
self._footer(h - 1, boxw)
|
|
66
|
+
stdscr.refresh()
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
now = status["now"]
|
|
70
|
+
caps = status.get("caps", {})
|
|
71
|
+
active = status.get("active", {})
|
|
72
|
+
hist_decode = status.get("history", {}).get("decode", [])
|
|
73
|
+
hist_gpu = status.get("history", {}).get("gpu", [])
|
|
74
|
+
|
|
75
|
+
y = 2
|
|
76
|
+
total_running = total_cap = total_tok = total_tok_cap = 0
|
|
77
|
+
for i, dp in enumerate(sorted(set(active) | set(caps))):
|
|
78
|
+
pair = th.PAIR_TITLE_CYAN if i % 2 == 0 else th.PAIR_TITLE_MAGENTA
|
|
79
|
+
y = self._dp_panel(y, boxw, dp, pair, active.get(dp, {}), caps.get(dp, {}), hist_decode, now)
|
|
80
|
+
cap = caps.get(dp, {})
|
|
81
|
+
a = active.get(dp, {})
|
|
82
|
+
total_running += a.get("running", 0)
|
|
83
|
+
total_cap += cap.get("max_running", 0)
|
|
84
|
+
total_tok += a.get("tokens", 0)
|
|
85
|
+
total_tok_cap += cap.get("max_tokens", 0)
|
|
86
|
+
|
|
87
|
+
y = self._total_panel(y, boxw, total_running, total_cap, status["summary"])
|
|
88
|
+
y = self._latency_panel(y, boxw, status.get("prom", {}))
|
|
89
|
+
y = self._gpu_panel(y, boxw, status.get("gpus", []), hist_gpu, now)
|
|
90
|
+
|
|
91
|
+
errors = status.get("errors", [])
|
|
92
|
+
if errors:
|
|
93
|
+
th.safe_addstr(stdscr, y, 0, f"⚠ {len(errors)} error(s) in the last 5min — see logs/deploy.log",
|
|
94
|
+
curses.color_pair(th.PAIR_BAD))
|
|
95
|
+
y += 1
|
|
96
|
+
|
|
97
|
+
self._footer(h - 1, boxw)
|
|
98
|
+
stdscr.refresh()
|
|
99
|
+
|
|
100
|
+
def _header(self, y: int, boxw: int, status: dict | None) -> None:
|
|
101
|
+
stdscr = self.stdscr
|
|
102
|
+
up = bool(status and status.get("service_up"))
|
|
103
|
+
dot = "●" if up else "○"
|
|
104
|
+
dot_pair = th.PAIR_TITLE_CYAN if up else th.PAIR_BAD
|
|
105
|
+
state = f"online · pid {status.get('pid')}" if up and status else "offline"
|
|
106
|
+
uptime = human_uptime(status.get("uptime")) if status else "—"
|
|
107
|
+
clock = datetime.now().strftime("%H:%M:%S")
|
|
108
|
+
|
|
109
|
+
th.safe_addstr(stdscr, y, 0, "sgtop", curses.A_BOLD | curses.color_pair(th.PAIR_TITLE_CYAN))
|
|
110
|
+
th.safe_addstr(stdscr, y, 6, " SGLang concurrency monitor", curses.A_DIM)
|
|
111
|
+
right = f"{dot} {state} up {uptime} {clock}"
|
|
112
|
+
th.safe_addstr(stdscr, y, max(0, boxw - len(right)), dot, curses.color_pair(dot_pair))
|
|
113
|
+
th.safe_addstr(stdscr, y, max(0, boxw - len(right)) + 2, right[2:])
|
|
114
|
+
|
|
115
|
+
def _footer(self, y: int, boxw: int) -> None:
|
|
116
|
+
th.safe_addstr(self.stdscr, y, 0, "q quit", curses.A_DIM)
|
|
117
|
+
|
|
118
|
+
def _dp_panel(self, y: int, boxw: int, dp: str, title_pair: int, a: dict, cap: dict,
|
|
119
|
+
hist_decode: list, now: float) -> int:
|
|
120
|
+
stdscr = self.stdscr
|
|
121
|
+
h = 6
|
|
122
|
+
th.draw_box(stdscr, self.theme, y, 0, h, boxw, dp, title_pair)
|
|
123
|
+
inner_w = boxw - 4
|
|
124
|
+
running, queue, tokens = a.get("running", 0), a.get("queue", 0), a.get("tokens", 0)
|
|
125
|
+
max_running, max_tokens = cap.get("max_running", 0), cap.get("max_tokens", 0)
|
|
126
|
+
|
|
127
|
+
if max_running:
|
|
128
|
+
label = f"concurrency {running}/{max_running} ({running/max_running*100:4.1f}%) q={queue}"
|
|
129
|
+
bar_w = th.fit_meter_width(inner_w, label)
|
|
130
|
+
th.draw_meter(stdscr, self.theme, y + 1, 2, bar_w, running / max_running, label)
|
|
131
|
+
else:
|
|
132
|
+
th.safe_addstr(stdscr, y + 1, 2, "concurrency: cap unknown (waiting for startup log line)",
|
|
133
|
+
curses.color_pair(th.PAIR_DIM))
|
|
134
|
+
|
|
135
|
+
if max_tokens:
|
|
136
|
+
label = f"kv-cache {tokens}/{max_tokens} ({tokens/max_tokens*100:4.1f}%)"
|
|
137
|
+
bar_w = th.fit_meter_width(inner_w, label)
|
|
138
|
+
th.draw_meter(stdscr, self.theme, y + 2, 2, bar_w, tokens / max_tokens, label)
|
|
139
|
+
|
|
140
|
+
thr_series = bucket_series(hist_decode, "ts", lambda it: it["throughput"], now, WINDOW_S,
|
|
141
|
+
SPARK_WIDTH, filter_fn=lambda it: it.get("dp") == dp)
|
|
142
|
+
th.draw_sparkline(stdscr, y + 3, 2, thr_series, title_pair)
|
|
143
|
+
th.safe_addstr(stdscr, y + 3, 2 + SPARK_WIDTH + 2,
|
|
144
|
+
f"decode {a.get('throughput', 0):7.1f} tok/s (last 5min)")
|
|
145
|
+
|
|
146
|
+
th.safe_addstr(stdscr, y + 4, 2,
|
|
147
|
+
f"accept_len={a.get('accept_len', 0):.2f} accept_rate={a.get('accept_rate', 0)*100:5.1f}%")
|
|
148
|
+
return y + h + 1
|
|
149
|
+
|
|
150
|
+
def _total_panel(self, y: int, boxw: int, running: int, cap: int, summary: dict) -> int:
|
|
151
|
+
stdscr = self.stdscr
|
|
152
|
+
h = 4
|
|
153
|
+
th.draw_box(stdscr, self.theme, y, 0, h, boxw, "TOTAL", th.PAIR_TITLE_ORANGE)
|
|
154
|
+
inner_w = boxw - 4
|
|
155
|
+
if cap:
|
|
156
|
+
label = f"concurrency {running}/{cap} ({running/cap*100:4.1f}%)"
|
|
157
|
+
bar_w = th.fit_meter_width(inner_w, label)
|
|
158
|
+
th.draw_meter(stdscr, self.theme, y + 1, 2, bar_w, running / cap, label)
|
|
159
|
+
th.safe_addstr(stdscr, y + 2, 2,
|
|
160
|
+
f"req/min={summary.get('requests_1m', 0):<4d} req/5min={summary.get('requests_5m', 0):<5d} "
|
|
161
|
+
f"accept_rate(60s)={summary.get('accept_rate_60s', 0)*100:5.1f}%")
|
|
162
|
+
return y + h + 1
|
|
163
|
+
|
|
164
|
+
def _latency_panel(self, y: int, boxw: int, prom: dict) -> int:
|
|
165
|
+
"""TTFT / E2E / queue-time / cache-hit — everything that needs sglang
|
|
166
|
+
started with --enable-metrics. TTFT and E2E have no per-DP label
|
|
167
|
+
(sglang aggregates them server-wide), queue-time and cache-hit do."""
|
|
168
|
+
stdscr = self.stdscr
|
|
169
|
+
h = 5
|
|
170
|
+
th.draw_box(stdscr, self.theme, y, 0, h, boxw, "LATENCY (--enable-metrics, 5min)", th.PAIR_TITLE_MAGENTA)
|
|
171
|
+
|
|
172
|
+
if not prom.get("enabled"):
|
|
173
|
+
th.safe_addstr(stdscr, y + 2, 2,
|
|
174
|
+
"not enabled — add --enable-metrics to serve517.sh and restart to see this",
|
|
175
|
+
curses.color_pair(th.PAIR_DIM))
|
|
176
|
+
return y + h + 1
|
|
177
|
+
|
|
178
|
+
latency = prom.get("latency", {})
|
|
179
|
+
queue_by_dp = prom.get("queue_by_dp", {})
|
|
180
|
+
cache_by_dp = prom.get("cache_hit_by_dp", {})
|
|
181
|
+
|
|
182
|
+
def stat_row(row: int, label: str, s: dict | None) -> None:
|
|
183
|
+
if not s:
|
|
184
|
+
th.safe_addstr(stdscr, row, 2, f"{label:<5} no samples yet", curses.color_pair(th.PAIR_DIM))
|
|
185
|
+
return
|
|
186
|
+
th.safe_addstr(stdscr, row, 2,
|
|
187
|
+
f"{label:<5} mean={fmt_secs(s['mean'])} p50={fmt_secs(s['p50'])} "
|
|
188
|
+
f"p90={fmt_secs(s['p90'])} p99={fmt_secs(s['p99'])} n={s['count']:.0f}")
|
|
189
|
+
|
|
190
|
+
stat_row(y + 1, "TTFT", latency.get("ttft"))
|
|
191
|
+
stat_row(y + 2, "E2E", latency.get("e2e"))
|
|
192
|
+
|
|
193
|
+
parts = []
|
|
194
|
+
for dp in sorted(set(queue_by_dp) | set(cache_by_dp)):
|
|
195
|
+
q = queue_by_dp.get(dp)
|
|
196
|
+
qtxt = f"p50={fmt_secs(q['p50'])} p90={fmt_secs(q['p90'])}" if q else "no samples"
|
|
197
|
+
ch = cache_by_dp.get(dp)
|
|
198
|
+
chtxt = f" cache-hit={ch*100:4.1f}%" if ch is not None else ""
|
|
199
|
+
parts.append(f"DP{dp} queue {qtxt}{chtxt}")
|
|
200
|
+
th.safe_addstr(stdscr, y + 3, 2, " ".join(parts))
|
|
201
|
+
return y + h + 1
|
|
202
|
+
|
|
203
|
+
def _gpu_panel(self, y: int, boxw: int, gpus: list, hist_gpu: list, now: float) -> int:
|
|
204
|
+
stdscr = self.stdscr
|
|
205
|
+
n = max(1, len(gpus))
|
|
206
|
+
h = n + 2
|
|
207
|
+
th.draw_box(stdscr, self.theme, y, 0, h, boxw, "GPUs", th.PAIR_TITLE_CYAN)
|
|
208
|
+
inner_w = boxw - 4
|
|
209
|
+
bar_w = min(24, max(6, inner_w - 55))
|
|
210
|
+
for i, g in enumerate(gpus):
|
|
211
|
+
row = y + 1 + i
|
|
212
|
+
label = f"GPU{g['index']}"
|
|
213
|
+
th.safe_addstr(stdscr, row, 2, label)
|
|
214
|
+
end_x = th.draw_meter(stdscr, self.theme, row, 2 + len(label) + 1, bar_w, g["util"] / 100,
|
|
215
|
+
f"{g['util']:5.1f}%")
|
|
216
|
+
th.safe_addstr(stdscr, row, end_x + 2,
|
|
217
|
+
f"mem={g['mem_used']:6.0f}/{g['mem_total']:6.0f}MiB "
|
|
218
|
+
f"{g['temp']:3.0f}C {g['power']:5.1f}W")
|
|
219
|
+
return y + h + 1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import curses
|
|
5
|
+
|
|
6
|
+
from .app import App
|
|
7
|
+
from .data import DEFAULT_HOST, DEFAULT_PORT
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
ap = argparse.ArgumentParser(
|
|
12
|
+
description="btop-style terminal monitor for a live SGLang deployment. "
|
|
13
|
+
"Reads a running monitor_dashboard.py's /api/status endpoint — no local "
|
|
14
|
+
"file access, so it works against any host on the LAN serving one."
|
|
15
|
+
)
|
|
16
|
+
ap.add_argument("--host", default=DEFAULT_HOST, help=f"dashboard host (default: {DEFAULT_HOST})")
|
|
17
|
+
ap.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"dashboard port (default: {DEFAULT_PORT})")
|
|
18
|
+
ap.add_argument("--interval", type=float, default=1.0, help="refresh interval in seconds (default: 1.0)")
|
|
19
|
+
args = ap.parse_args()
|
|
20
|
+
|
|
21
|
+
def _run(stdscr):
|
|
22
|
+
App(stdscr, args.host, args.port, args.interval).run()
|
|
23
|
+
|
|
24
|
+
curses.wrapper(_run)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Networking + time-series helpers. No filesystem access: everything comes
|
|
2
|
+
from a running monitor_dashboard.py's /api/status endpoint, so sgtop can
|
|
3
|
+
point at any host on the LAN that's serving one."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
11
|
+
DEFAULT_PORT = 30001
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fetch_status(host: str, port: int, timeout: float = 2.0) -> Optional[dict]:
|
|
15
|
+
try:
|
|
16
|
+
with urllib.request.urlopen(f"http://{host}:{port}/api/status", timeout=timeout) as r:
|
|
17
|
+
return json.loads(r.read())
|
|
18
|
+
except Exception:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def bucket_series(
|
|
23
|
+
items: list[dict],
|
|
24
|
+
ts_key: str,
|
|
25
|
+
value_fn: Callable[[dict], Optional[float]],
|
|
26
|
+
now: float,
|
|
27
|
+
window_s: float,
|
|
28
|
+
width: int,
|
|
29
|
+
filter_fn: Optional[Callable[[dict], bool]] = None,
|
|
30
|
+
) -> list[float]:
|
|
31
|
+
"""Bucket `items` into `width` evenly-spaced buckets covering the last
|
|
32
|
+
`window_s` seconds, averaging value_fn(item) within each bucket. Empty
|
|
33
|
+
buckets come back as 0.0, so the sparkline reads as "idle", not "no data".
|
|
34
|
+
"""
|
|
35
|
+
if width <= 0:
|
|
36
|
+
return []
|
|
37
|
+
buckets: list[list[float]] = [[] for _ in range(width)]
|
|
38
|
+
start = now - window_s
|
|
39
|
+
step = window_s / width
|
|
40
|
+
for item in items:
|
|
41
|
+
if filter_fn is not None and not filter_fn(item):
|
|
42
|
+
continue
|
|
43
|
+
ts = item.get(ts_key)
|
|
44
|
+
if ts is None or ts < start:
|
|
45
|
+
continue
|
|
46
|
+
idx = min(width - 1, max(0, int((ts - start) / step)))
|
|
47
|
+
v = value_fn(item)
|
|
48
|
+
if v is not None:
|
|
49
|
+
buckets[idx].append(v)
|
|
50
|
+
return [sum(b) / len(b) if b else 0.0 for b in buckets]
|