Skip to content

Commit 1576e43

Browse files
authored
feat: slo goodput customization (#23)
* feat: customizable per-model SLO goodput thresholds The SLO Goodput sub-panel now has a gear icon that opens a small editor for TTFT/ITL/E2E thresholds. Settings persist in localStorage keyed by engine+model so each served model keeps its own SLOs across reloads. Backend ships raw histogram buckets alongside the existing pre-computed goodput percentages so the frontend can recompute goodput at the user's custom thresholds without a roundtrip. Pre-computed percentages remain as the warmup/no-data fallback. * fix(dev): build and sync frontend/dist so embedded :3000 isn't stale dev.sh now runs npm run build before the initial sync and stops excluding frontend/dist from rsync, so the backend cargo build embeds the current bundle. Adds an optional --watch-frontend flag that rebuilds dist + syncs + rebuilds backend on frontend changes (off by default — Vite at :5173 stays the fast path; the flag is for keeping the embedded :3000 build live too). * feat(frontend): recognize LiquidAI as a model provider Add LiquidAI to the provider logo registry so models from the HuggingFace org `LiquidAI/...` and bare LFM/LFM2 model ids resolve to the new `liquid-ai` SVG asset.
1 parent e6d71e4 commit 1576e43

15 files changed

Lines changed: 1009 additions & 37 deletions

File tree

dev/README.md

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,40 @@ For **production installs**, use `cargo install spark-dashboard` or
1212

1313
Runs the full dev environment:
1414

15-
1. rsyncs the project to `${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}`
16-
2. builds and starts the Rust backend on the remote host (`cargo build --release`)
17-
3. starts the Vite dev server locally on port 5173 with a proxy to the backend
18-
4. streams remote backend logs from `/tmp/spark-dashboard.log`
19-
5. watches `src/` and `Cargo.toml` — on change, re-syncs and rebuilds the backend
20-
21-
Frontend edits hot-reload in the browser via Vite. Backend edits trigger a
22-
remote rebuild (takes about as long as `cargo build --release` does on your
23-
remote host).
15+
1. builds the frontend bundle locally (`npm run build` in `frontend/`) so the
16+
embedded assets shipped to the remote backend are current
17+
2. rsyncs the project (including `frontend/dist/`) to `${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}`
18+
3. builds and starts the Rust backend on the remote host (`cargo build --release`,
19+
which embeds the freshly-built `frontend/dist/`)
20+
4. starts the Vite dev server locally on port 5173 with a proxy to the backend
21+
5. streams remote backend logs from `/tmp/spark-dashboard.log`
22+
6. watches `src/` and `Cargo.toml` — on change, re-syncs and rebuilds the backend
23+
24+
Two URLs, two behaviors:
25+
26+
- `http://localhost:5173` — Vite dev server. Frontend edits hot-reload in the
27+
browser, API/WS calls proxy to the remote backend.
28+
- `http://${DEPLOY_HOST}:3000` — the remote backend serving the **embedded**
29+
bundle that was built when `dev.sh` started. To refresh it during a session,
30+
re-run `npm run build` locally and trigger any backend file change (or just
31+
restart `dev.sh`) so the next sync + `cargo build --release` re-embeds the
32+
fresh `frontend/dist/`.
33+
34+
Backend edits trigger a remote rebuild (takes about as long as
35+
`cargo build --release` does on your remote host).
36+
37+
#### `--watch-frontend` (optional)
38+
39+
Pass `./dev/dev.sh --watch-frontend` to also watch `frontend/src/`,
40+
`frontend/public/`, `frontend/index.html`, `vite.config.ts`, and
41+
`package.json`. On change the script rebuilds `frontend/dist/`, re-syncs to the
42+
remote, and rebuilds the backend — so direct hits on
43+
`http://${DEPLOY_HOST}:3000` refresh too.
44+
45+
Off by default because each save triggers a full `npm run build` plus
46+
`cargo build --release` (~10–30s on a typical remote). For normal frontend dev,
47+
use `:5173` (Vite, instant HMR) and only enable this flag when you specifically
48+
need the embedded bundle to stay current.
2449

2550
## Required environment variables
2651

dev/dev.sh

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,33 @@ set -euo pipefail
33

44
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
55

6+
# --- CLI flags ---------------------------------------------------------------
7+
WATCH_FRONTEND=false
8+
for arg in "$@"; do
9+
case "$arg" in
10+
--watch-frontend)
11+
WATCH_FRONTEND=true
12+
;;
13+
-h|--help)
14+
cat <<EOF
15+
Usage: dev.sh [--watch-frontend]
16+
17+
--watch-frontend Also watch frontend/ — on change, rebuild frontend/dist,
18+
re-sync, and rebuild the backend so the embedded bundle on
19+
:3000 stays current. Off by default (Vite at :5173 is the
20+
fast path for frontend dev; this flag is for live-updating
21+
the embedded build too, at the cost of a cargo rebuild per
22+
frontend change).
23+
EOF
24+
exit 0
25+
;;
26+
*)
27+
echo "unknown flag: $arg (try --help)" >&2
28+
exit 2
29+
;;
30+
esac
31+
done
32+
633
# --- Load .env if present ----------------------------------------------------
734
if [ -f "$PROJECT_ROOT/.env" ]; then
835
set -a
@@ -49,6 +76,18 @@ trap cleanup EXIT INT TERM
4976
# --- Remote shell prefix: ensure cargo is in PATH for non-interactive SSH ---
5077
REMOTE_ENV="source ~/.cargo/env 2>/dev/null;"
5178

79+
# --- Build the frontend bundle locally so rust-embed picks up a fresh dist ---
80+
# Direct hits to the backend on :3000 serve the embedded bundle, so this needs
81+
# to run before we sync + rebuild the backend.
82+
build_frontend() {
83+
if [ ! -d "${PROJECT_ROOT}/frontend/node_modules" ]; then
84+
echo "==> Installing frontend dependencies..."
85+
(cd "${PROJECT_ROOT}/frontend" && npm install --silent)
86+
fi
87+
echo "==> Building frontend bundle (frontend/dist)..."
88+
(cd "${PROJECT_ROOT}/frontend" && npm run build)
89+
}
90+
5291
# --- Sync backend source to remote host ---
5392
sync_backend() {
5493
rsync -az --delete \
@@ -58,7 +97,6 @@ sync_backend() {
5897
--exclude .env \
5998
--exclude .planning \
6099
--exclude .claude \
61-
--exclude 'frontend/dist' \
62100
"${PROJECT_ROOT}/" "${REMOTE}:${DEPLOY_DIR}/"
63101
}
64102

@@ -75,6 +113,54 @@ rebuild_backend() {
75113
fi
76114
}
77115

116+
# --- Frontend watcher: rebuild dist + sync + rebuild backend on change ---
117+
# Only launched when --watch-frontend is passed. Heavy: each frontend save
118+
# triggers `npm run build` plus a remote `cargo build --release`.
119+
watch_frontend() {
120+
local trigger="$1" # message printed when a change fires
121+
local watch_paths=(
122+
"${PROJECT_ROOT}/frontend/src"
123+
"${PROJECT_ROOT}/frontend/public"
124+
"${PROJECT_ROOT}/frontend/index.html"
125+
"${PROJECT_ROOT}/frontend/vite.config.ts"
126+
"${PROJECT_ROOT}/frontend/package.json"
127+
)
128+
129+
rebuild_embedded() {
130+
echo ""
131+
echo "==> ${trigger}"
132+
build_frontend
133+
sync_backend
134+
rebuild_backend
135+
}
136+
137+
if command -v fswatch &>/dev/null; then
138+
fswatch -0 -r -l 2 \
139+
--exclude '.*node_modules.*' \
140+
--exclude '.*frontend/dist.*' \
141+
"${watch_paths[@]}" \
142+
| while IFS= read -r -d '' _; do
143+
while read -r -d '' -t 0.5 _ 2>/dev/null; do :; done
144+
rebuild_embedded
145+
done
146+
else
147+
local last_hash=""
148+
while true; do
149+
local current_hash
150+
current_hash=$(find "${watch_paths[@]}" \
151+
-type f \
152+
! -path '*/node_modules/*' \
153+
! -path '*/dist/*' \
154+
-exec stat -f '%m %N' {} + 2>/dev/null | sort | md5)
155+
if [ -n "$last_hash" ] && [ "$current_hash" != "$last_hash" ]; then
156+
rebuild_embedded
157+
fi
158+
last_hash="$current_hash"
159+
sleep 2
160+
done
161+
fi
162+
}
163+
78164
# --- File watcher: fswatch if available, else polling fallback ---
79165
watch_backend() {
80166
if command -v fswatch &>/dev/null; then
@@ -110,7 +196,8 @@ watch_backend() {
110196
fi
111197
}
112198

113-
# 1. Sync and build backend
199+
# 1. Build embedded frontend bundle, then sync and build backend
200+
build_frontend
114201
echo "==> Syncing to ${REMOTE}:${DEPLOY_DIR}..."
115202
sync_backend
116203
rebuild_backend
@@ -119,21 +206,15 @@ rebuild_backend
119206
ssh "${REMOTE}" "tail -n0 -f /tmp/spark-dashboard.log" 2>/dev/null &
120207
PIDS+=($!)
121208

122-
# 3. Install frontend deps if needed
123-
if [ ! -d "${PROJECT_ROOT}/frontend/node_modules" ]; then
124-
echo "==> Installing frontend dependencies..."
125-
(cd "${PROJECT_ROOT}/frontend" && npm install --silent)
126-
fi
127-
128-
# 4. Start Vite dev server
209+
# 3. Start Vite dev server
129210
BACKEND_URL="${VITE_BACKEND_URL:-http://localhost:3000}"
130211
echo "==> Starting Vite dev server (proxy -> ${BACKEND_URL})..."
131212
cd "${PROJECT_ROOT}/frontend"
132213
VITE_BACKEND_URL="${BACKEND_URL}" npx vite --host &
133214
PIDS+=($!)
134215
cd "${PROJECT_ROOT}"
135216

136-
# 5. Watch for backend changes
217+
# 4. Watch for backend changes
137218
if command -v fswatch &>/dev/null; then
138219
echo "==> Watching backend changes (fswatch)..."
139220
else
@@ -142,6 +223,14 @@ fi
142223
watch_backend &
143224
PIDS+=($!)
144225

226+
# 5. Optionally watch frontend changes (off by default — Vite at :5173 is the
227+
# fast path; this only matters if you want :3000 to stay current too).
228+
if [ "$WATCH_FRONTEND" = true ]; then
229+
echo "==> Watching frontend changes (--watch-frontend) — embedded :3000 will refresh on save"
230+
watch_frontend "Frontend change detected" &
231+
PIDS+=($!)
232+
fi
233+
145234
echo ""
146235
echo "================================================"
147236
echo " Frontend (Vite): http://localhost:5173"
Lines changed: 1 addition & 0 deletions
Loading

frontend/src/__tests__/slo.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
combinedGoodput,
4+
DEFAULT_SLO,
5+
fractionLe,
6+
recomputeGoodputPct,
7+
SLO,
8+
} from '@/lib/slo'
9+
import type { HistogramBucket } from '@/types/metrics'
10+
11+
const OVERFLOW = Number.MAX_VALUE
12+
13+
function buckets(...pairs: [number, number][]): HistogramBucket[] {
14+
return pairs.map(([le, cum]) => ({ le_seconds: le, cumulative_count: cum }))
15+
}
16+
17+
const APPROX = 1e-9
18+
19+
describe('DEFAULT_SLO mirrors backend constants', () => {
20+
it('matches the Rust thresholds (TTFT 500ms, ITL 50ms, E2E 5000ms)', () => {
21+
expect(DEFAULT_SLO).toEqual({ ttftMs: 500, itlMs: 50, e2eMs: 5000 })
22+
})
23+
24+
it('keeps SLO as a backwards-compatible alias of DEFAULT_SLO', () => {
25+
expect(SLO).toBe(DEFAULT_SLO)
26+
})
27+
})
28+
29+
// The fractionLe tests below are direct ports of the Rust fraction_le tests
30+
// in src/engines/histogram.rs. Keeping the assertions identical guarantees
31+
// the TS port stays in lockstep with the backend computation.
32+
describe('fractionLe (TS port of Rust fraction_le)', () => {
33+
it('returns null for empty buckets', () => {
34+
expect(fractionLe([], 0.5)).toBeNull()
35+
expect(fractionLe(null, 0.5)).toBeNull()
36+
})
37+
38+
it('returns null when total count is zero', () => {
39+
const b = buckets([0.1, 0], [1.0, 0], [OVERFLOW, 0])
40+
expect(fractionLe(b, 0.5)).toBeNull()
41+
})
42+
43+
it('matches at the bucket boundary', () => {
44+
// 50 obs at <= 0.1, 100 total. Threshold == 0.1 → 50/100 = 0.5.
45+
const b = buckets([0.1, 50], [1.0, 100])
46+
expect(fractionLe(b, 0.1)).toBeCloseTo(0.5, 9)
47+
})
48+
49+
it('linearly interpolates within a bucket', () => {
50+
// 25 by 0.1s, 100 by 1.0s. Threshold 0.4 sits inside bucket 1.
51+
// bucket has 75 obs over (0.1, 1.0]; (0.4 - 0.1) / 0.9 = 1/3
52+
// → cum = 25 + (1/3)*75 = 50 → 50/100 = 0.5.
53+
const b = buckets([0.1, 25], [1.0, 100])
54+
expect(fractionLe(b, 0.4)).toBeCloseTo(0.5, 9)
55+
})
56+
57+
it('linearly interpolates from zero when threshold is below the first bucket', () => {
58+
// 100 obs in [0, 0.1]. Threshold 0.05 → assume uniform within bucket
59+
// → 50/100 = 0.5.
60+
const b = buckets([0.1, 100], [1.0, 100])
61+
expect(fractionLe(b, 0.05)).toBeCloseTo(0.5, 9)
62+
})
63+
64+
it('caps at the last finite cumulative count above finite bounds', () => {
65+
// 99 in (0, 1.0], 1 in overflow. Threshold 5.0 → 99/100 = 0.99.
66+
const b = buckets([1.0, 99], [OVERFLOW, 100])
67+
expect(fractionLe(b, 5.0)).toBeCloseTo(0.99, 9)
68+
})
69+
70+
it('saturates at 1 when threshold exceeds all buckets and no overflow exists', () => {
71+
const b = buckets([0.1, 10], [1.0, 100])
72+
expect(fractionLe(b, 5.0)).toBeCloseTo(1.0, 9)
73+
})
74+
75+
it('returns 0 when first bucket le is zero or negative', () => {
76+
const b = buckets([0.0, 0], [1.0, 100])
77+
expect(fractionLe(b, -1.0)).toBeCloseTo(0.0, 9)
78+
})
79+
80+
it('returns null for NaN threshold', () => {
81+
const b = buckets([0.1, 50], [1.0, 100])
82+
expect(fractionLe(b, Number.NaN)).toBeNull()
83+
})
84+
})
85+
86+
describe('recomputeGoodputPct', () => {
87+
it('returns the percentage 0–100 from millisecond thresholds', () => {
88+
// 50/100 obs at <= 100ms → 50% goodput at TTFT ≤ 100ms.
89+
const b = buckets([0.1, 50], [1.0, 100])
90+
const got = recomputeGoodputPct(b, 100)
91+
expect(got).not.toBeNull()
92+
expect(got!).toBeCloseTo(50, 9)
93+
})
94+
95+
it('returns null when buckets are missing', () => {
96+
expect(recomputeGoodputPct(null, 500)).toBeNull()
97+
expect(recomputeGoodputPct([], 500)).toBeNull()
98+
})
99+
100+
it('agrees with the backend default goodput within rounding', () => {
101+
// Backend with TTFT_SLO_MS=500: vllm test data
102+
// 50 at <=0.05, 80 at <=0.1, 95 at <=0.5, 99 at <=1.0, 100 at +Inf.
103+
// fractionLe at threshold 0.5s → exactly 95/100 = 0.95 → 95.0%.
104+
const b = buckets(
105+
[0.05, 50],
106+
[0.1, 80],
107+
[0.5, 95],
108+
[1.0, 99],
109+
[OVERFLOW, 100],
110+
)
111+
const got = recomputeGoodputPct(b, 500)!
112+
expect(Math.abs(got - 95)).toBeLessThan(APPROX)
113+
})
114+
115+
it('drops sharply when the user tightens the threshold', () => {
116+
const b = buckets(
117+
[0.05, 50],
118+
[0.1, 80],
119+
[0.5, 95],
120+
[1.0, 99],
121+
[OVERFLOW, 100],
122+
)
123+
// At a 50ms threshold only the [0, 0.05] bucket fully qualifies → 50%.
124+
expect(recomputeGoodputPct(b, 50)!).toBeCloseTo(50, 9)
125+
})
126+
})
127+
128+
describe('combinedGoodput', () => {
129+
it('returns the minimum of the present values', () => {
130+
expect(combinedGoodput(99, 95, 88)).toBe(88)
131+
})
132+
133+
it('ignores nulls and non-finite values', () => {
134+
expect(combinedGoodput(99, null, 88)).toBe(88)
135+
expect(combinedGoodput(99, Number.NaN, 50)).toBe(50)
136+
})
137+
138+
it('returns null when all inputs are null', () => {
139+
expect(combinedGoodput(null, null, null)).toBeNull()
140+
})
141+
})

0 commit comments

Comments
 (0)