2026-09-04 22:31:51 -07:00
#!/usr/bin/env python3
"""Generate consolidated Grafana dashboards as k8s ConfigMap YAML files."""
import json
import os
DASHBOARD_DIR = os . path . expanduser ( "~/workplace/homelab/k8s/infra/monitoring/dashboards" )
DS_PROM = { "type" : "prometheus" , "uid" : "prometheus" }
DS_LOKI = { "type" : "loki" , "uid" : "loki" }
def stat_panel ( id , title , expr , x , y , w = 4 , h = 4 , unit = "short" , mappings = None , thresholds = None ):
p = {
"id" : id , "title" : title , "type" : "stat" ,
"gridPos" : { "h" : h , "w" : w , "x" : x , "y" : y },
"datasource" : DS_PROM ,
"fieldConfig" : { "defaults" : { "unit" : unit }},
"targets" : [{ "expr" : expr }],
}
if mappings :
p [ "fieldConfig" ][ "defaults" ][ "mappings" ] = mappings
if thresholds :
p [ "fieldConfig" ][ "defaults" ][ "thresholds" ] = thresholds
p [ "fieldConfig" ][ "defaults" ][ "color" ] = { "mode" : "thresholds" }
return p
2026-09-30 19:49:05 +08:00
def ts_panel ( id , title , exprs , x , y , w = 8 , h = 8 , unit = "short" , description = None ):
2026-09-04 22:31:51 -07:00
targets = []
for e in exprs :
if isinstance ( e , tuple ):
targets . append ({ "expr" : e [ 0 ], "legendFormat" : e [ 1 ]})
else :
targets . append ({ "expr" : e , "legendFormat" : "{{pod}}" })
2026-09-30 19:49:05 +08:00
p = {
2026-09-04 22:31:51 -07:00
"id" : id , "title" : title , "type" : "timeseries" ,
"gridPos" : { "h" : h , "w" : w , "x" : x , "y" : y },
"datasource" : DS_PROM ,
"fieldConfig" : { "defaults" : { "unit" : unit }},
"targets" : targets ,
}
2026-09-30 19:49:05 +08:00
if description :
p [ "description" ] = description
return p
2026-09-04 22:31:51 -07:00
def table_panel ( id , title , expr , x , y , w = 12 , h = 8 ):
return {
"id" : id , "title" : title , "type" : "table" ,
"gridPos" : { "h" : h , "w" : w , "x" : x , "y" : y },
"datasource" : DS_PROM ,
"targets" : [{ "expr" : expr , "format" : "table" , "instant" : True }],
}
def log_panel ( id , title , query , x , y , w = 24 , h = 10 ):
return {
"id" : id , "title" : title , "type" : "logs" ,
"gridPos" : { "h" : h , "w" : w , "x" : x , "y" : y },
"datasource" : DS_LOKI ,
"targets" : [{ "expr" : query }],
}
def row ( id , title , y , panels , collapsed = True ):
return {
"id" : id , "title" : title , "type" : "row" ,
"collapsed" : collapsed , "gridPos" : { "h" : 1 , "w" : 24 , "x" : 0 , "y" : y },
"panels" : panels ,
}
def write_dashboard ( filename , dashboard , folder ):
cm = {
"apiVersion" : "v1" ,
"kind" : "ConfigMap" ,
"metadata" : {
"name" : filename . replace ( ".yaml" , "-dashboard" ),
"namespace" : "logging" ,
"labels" : { "grafana_dashboard" : "1" },
"annotations" : { "grafana_folder" : folder },
},
"data" : {
filename . replace ( ".yaml" , ".json" ): json . dumps ( dashboard , separators = ( "," , ":" ))
},
}
import yaml
path = os . path . join ( DASHBOARD_DIR , filename )
with open ( path , "w" ) as f :
yaml . dump ( cm , f , default_flow_style = False , allow_unicode = True )
print ( f " wrote { path } " )
# ============================================================================
# Dashboard 1: Cluster Infrastructure
# ============================================================================
def build_cluster_infrastructure ():
zero_thresholds = { "mode" : "absolute" , "steps" : [
{ "value" : None , "color" : "green" }, { "value" : 1 , "color" : "red" }
]}
panels = [
row ( 1 , "Cluster Health" , 0 , [
stat_panel ( 2 , "Nodes Ready" , 'count(kube_node_status_condition{condition="Ready",status="true"} == 1)' , 0 , 1 , thresholds = { "mode" : "absolute" , "steps" :[{ "value" : None , "color" : "red" },{ "value" : 3 , "color" : "green" }]}),
stat_panel ( 3 , "Pods Pending" , 'sum(kube_pod_status_phase{phase="Pending"}) OR on() vector(0)' , 4 , 1 , thresholds = zero_thresholds ),
stat_panel ( 4 , "CrashLoopBackOff" , 'sum(kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"}) OR on() vector(0)' , 8 , 1 , thresholds = zero_thresholds ),
stat_panel ( 5 , "OOMKilled (1h)" , 'sum(increase(kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}[1h])) OR on() vector(0)' , 12 , 1 , thresholds = zero_thresholds ),
stat_panel ( 6 , "Deploys Unavailable" , 'count(kube_deployment_status_replicas_unavailable > 0) OR on() vector(0)' , 16 , 1 , thresholds = zero_thresholds ),
stat_panel ( 7 , "Services Down" , 'count(probe_success == 0) OR on() vector(0)' , 20 , 1 , thresholds = zero_thresholds ),
]),
row ( 10 , "Jobs & CronJobs" , 1 , [
stat_panel ( 11 , "Failed Jobs" , 'count(kube_job_status_failed > 0) OR on() vector(0)' , 0 , 2 , thresholds = zero_thresholds ),
table_panel ( 12 , "Failed Jobs Detail" , 'kube_job_status_failed > 0' , 4 , 2 , w = 10 ),
table_panel ( 13 , "Stuck Jobs (>1h)" , 'kube_job_status_active == 1 and on(job_name,namespace) (time() - kube_job_status_start_time) > 3600' , 14 , 2 , w = 10 ),
ts_panel ( 14 , "CronJob Last Success" , [
( 'kube_cronjob_status_last_successful_time{namespace=~"cicd|kube-system|paperless"}' , "{{namespace}}/{{cronjob}}" )
], 0 , 10 , w = 12 , unit = "dateTimeFromNow" ),
ts_panel ( 15 , "Container Restart Storm (top 10)" , [
( 'topk(10, sum(rate(kube_pod_container_status_restarts_total[15m])) by (namespace, pod))' , "{{namespace}}/{{pod}}" )
], 12 , 10 , w = 12 ),
]),
row ( 20 , "Node Resources" , 2 , [
ts_panel ( 21 , "CPU % by Node" , [
( '(1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) by (instance)) * 100' , "{{instance}}" )
], 0 , 3 , unit = "percent" ),
ts_panel ( 22 , "Memory % by Node" , [
( '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100' , "{{instance}}" )
], 8 , 3 , unit = "percent" ),
ts_panel ( 23 , "Disk % by Node" , [
( '(1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100' , "{{instance}}" )
], 16 , 3 , unit = "percent" ),
ts_panel ( 24 , "Load Average" , [
( "node_load1" , "1m {{instance}}" ),
( "node_load5" , "5m {{instance}}" ),
], 0 , 11 ),
ts_panel ( 25 , "Network Errors & Drops" , [
( "rate(node_network_receive_errs_total[5m])" , "rx-err {{instance}}" ),
( "rate(node_network_transmit_errs_total[5m])" , "tx-err {{instance}}" ),
( "rate(node_network_receive_drop_total[5m])" , "rx-drop {{instance}}" ),
], 8 , 11 ),
]),
row ( 30 , "Control Plane" , 3 , [
stat_panel ( 31 , "API Server Up" , 'min(up{job="apiserver"})' , 0 , 4 , mappings = [
{ "type" : "value" , "options" :{ "0" :{ "text" : "DOWN" , "color" : "red" }, "1" :{ "text" : "UP" , "color" : "green" }}}
]),
ts_panel ( 32 , "API Server Request Rate" , [
( 'sum(rate(apiserver_request_total[5m])) by (verb, code)' , "{{verb}} {{code}}" )
], 4 , 4 , w = 10 ),
ts_panel ( 33 , "API Server Error Rate %" , [
( 'sum(rate(apiserver_request_total{code=~"5.."}[5m])) / sum(rate(apiserver_request_total[5m])) * 100' , "5xx %" )
], 14 , 4 , w = 10 , unit = "percent" ),
ts_panel ( 34 , "API Server Latency" , [
( 'histogram_quantile(0.95, sum(rate(apiserver_request_duration_seconds_bucket[5m])) by (le))' , "p95" ),
( 'histogram_quantile(0.99, sum(rate(apiserver_request_duration_seconds_bucket[5m])) by (le))' , "p99" ),
], 0 , 12 , unit = "s" ),
ts_panel ( 35 , "etcd Request Duration" , [
( 'histogram_quantile(0.99, sum(rate(etcd_request_duration_seconds_bucket[5m])) by (le))' , "p99" ),
], 8 , 12 , unit = "s" ),
]),
row ( 40 , "Storage" , 4 , [
ts_panel ( 41 , "Longhorn Disk Capacity" , [
( "longhorn_disk_capacity_bytes" , "capacity {{node}}" ),
( "longhorn_disk_reservation_bytes" , "reserved {{node}}" ),
], 0 , 5 , unit = "bytes" ),
ts_panel ( 42 , "PVC Phase" , [
( 'kube_persistentvolumeclaim_status_phase' , "{{namespace}}/{{persistentvolumeclaim}} {{phase}}" )
], 8 , 5 ),
]),
row ( 50 , "DNS & Networking" , 5 , [
ts_panel ( 51 , "CoreDNS Cache Hit Rate" , [
( 'rate(coredns_cache_hits_total[5m]) / (rate(coredns_cache_hits_total[5m]) + rate(coredns_cache_misses_total[5m]))' , "{{server}}" )
], 0 , 6 , unit = "percentunit" ),
ts_panel ( 52 , "CoreDNS Errors" , [
( 'sum(rate(coredns_dns_responses_total{rcode=~"SERVFAIL|NXDOMAIN"}[5m])) by (rcode)' , "{{rcode}}" )
], 8 , 6 ),
]),
row ( 60 , "Logs" , 6 , [
ts_panel ( 61 , "Error Rate by Namespace" , [
( 'sum by (namespace) (count_over_time({namespace=~"kube-system|cert-manager|ingress-nginx|longhorn-system"} |= "error" [5m]))' , "{{namespace}}" )
], 0 , 7 ),
log_panel ( 62 , "Control Plane Logs" , '{namespace="kube-system"}' , 0 , 15 ),
log_panel ( 63 , "Cluster Addon Logs" , '{namespace=~"cert-manager|ingress-nginx|longhorn-system"}' , 0 , 25 ),
]),
]
return {
"title" : "Cluster Infrastructure" ,
"uid" : "cluster-infra" ,
"schemaVersion" : 39 ,
"timezone" : "browser" ,
"time" : { "from" : "now-6h" , "to" : "now" },
"refresh" : "30s" ,
"tags" : [ "infrastructure" , "k8s" ],
"panels" : panels ,
}
# ============================================================================
# Dashboard 3: API Gateway
# ============================================================================
def build_api_gateway ():
panels = [
row ( 1 , "Gateway Health" , 0 , [
stat_panel ( 2 , "Gateway Pods Ready" , 'sum(kube_pod_status_ready{namespace="api",condition="true"})' , 0 , 1 , thresholds = { "mode" : "absolute" , "steps" :[{ "value" : None , "color" : "red" },{ "value" : 3 , "color" : "green" }]}),
stat_panel ( 3 , "Probe: healthz" , 'probe_success{instance=~".*api.riotpiao.com/healthz"}' , 4 , 1 , mappings = [
{ "type" : "value" , "options" :{ "0" :{ "text" : "DOWN" , "color" : "red" }, "1" :{ "text" : "UP" , "color" : "green" }}}
]),
ts_panel ( 4 , "Probe Latency" , [
( 'probe_duration_seconds{instance=~".*api.riotpiao.com.*"}' , "{{instance}}" )
], 8 , 1 , unit = "s" ),
], collapsed = False ),
row ( 10 , "Ingress Traffic (nginx)" , 1 , [
ts_panel ( 11 , "Request Rate by Status" , [
( 'sum(rate(nginx_ingress_controller_requests{ingress="api"}[5m])) by (status)' , "{{status}}" )
], 0 , 2 ),
ts_panel ( 12 , "Error Rate %" , [
( 'sum(rate(nginx_ingress_controller_requests{ingress="api",status=~"5.."}[5m])) / sum(rate(nginx_ingress_controller_requests{ingress="api"}[5m])) * 100' , "5xx" ),
( 'sum(rate(nginx_ingress_controller_requests{ingress="api",status=~"4.."}[5m])) / sum(rate(nginx_ingress_controller_requests{ingress="api"}[5m])) * 100' , "4xx" ),
], 8 , 2 , unit = "percent" ),
ts_panel ( 13 , "Latency p50/p95/p99" , [
( 'histogram_quantile(0.50, sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{ingress="api"}[5m])) by (le))' , "p50" ),
( 'histogram_quantile(0.95, sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{ingress="api"}[5m])) by (le))' , "p95" ),
( 'histogram_quantile(0.99, sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{ingress="api"}[5m])) by (le))' , "p99" ),
], 16 , 2 , unit = "s" ),
]),
row ( 20 , "LLM Serving" , 2 , [
stat_panel ( 21 , "LLM Pods Ready" , 'sum(kube_pod_status_ready{namespace="llm-serving",condition="true"})' , 0 , 3 ),
ts_panel ( 22 , "CPU by Predictor" , [
( 'sum(rate(container_cpu_usage_seconds_total{namespace="llm-serving"}[5m])) by (pod)' , "{{pod}}" )
], 4 , 3 ),
ts_panel ( 23 , "Memory by Predictor" , [
( 'sum(container_memory_working_set_bytes{namespace="llm-serving"}) by (pod)' , "{{pod}}" )
], 12 , 3 , unit = "bytes" ),
ts_panel ( 24 , "Predictor Restarts" , [
( 'sum(rate(kube_pod_container_status_restarts_total{namespace="llm-serving"}[15m])) by (pod)' , "{{pod}}" )
], 20 , 3 , w = 4 ),
]),
row ( 30 , "Gateway Resources" , 3 , [
ts_panel ( 31 , "CPU by Gateway Pod" , [
( 'sum(rate(container_cpu_usage_seconds_total{namespace="api"}[5m])) by (pod)' , "{{pod}}" )
], 0 , 4 ),
ts_panel ( 32 , "Memory by Gateway Pod" , [
( 'sum(container_memory_working_set_bytes{namespace="api"}) by (pod)' , "{{pod}}" )
], 8 , 4 , unit = "bytes" ),
ts_panel ( 33 , "Gateway Restarts" , [
( 'sum(rate(kube_pod_container_status_restarts_total{namespace="api"}[15m])) by (pod)' , "{{pod}}" )
], 16 , 4 ),
]),
row ( 40 , "Logs" , 4 , [
log_panel ( 41 , "Gateway Logs" , '{namespace="api",container="gateway"}' , 0 , 5 ),
log_panel ( 42 , "LLM Serving Logs" , '{namespace="llm-serving"}' , 0 , 15 ),
]),
]
return {
"title" : "API Gateway" ,
"uid" : "api-gateway" ,
"schemaVersion" : 39 ,
"timezone" : "browser" ,
"time" : { "from" : "now-6h" , "to" : "now" },
"refresh" : "30s" ,
"tags" : [ "api" , "gateway" , "llm" ],
"panels" : panels ,
}
2026-09-29 22:11:28 +08:00
# ============================================================================
# Dashboard 4: LLM Observability (vLLM / TEI engines + gateway)
# ============================================================================
#
# Two independent metric sources, deliberately both shown:
#
# vLLM + TEI (llm-serving) — engine-truth, but only for the three engines
# that expose /metrics. Ollama (ornith:35b,
# qwen2.5:3b-instruct) exposes nothing at all.
2026-09-30 19:49:05 +08:00
# api-gateway (/metrics) — covers EVERY model including the two Ollama
# ones, derived from the SSE byte stream: TTFT is
# request-start to first byte, ITL is the gap
# between SSE writes carrying "data: ", so it
# counts SSE chunks rather than true tokens.
# vLLM's own histograms remain authoritative for
# `reasoning`.
2026-09-29 22:47:38 +08:00
#
2026-09-30 19:49:05 +08:00
# These three series ARE live as of 2026-09-29 — 3 series each, one per replica,
# verified against the running gateway and Prometheus:
# llm_ttft_seconds, llm_itl_seconds, llm_tokens_total
2026-09-29 22:47:38 +08:00
#
2026-09-30 19:49:05 +08:00
# History, recorded so nobody re-derives it the hard way: on 2026-09-29 these
# recorded nothing at all. internal/proxy/llm_metrics.go declared a capture
# wrapper that was never constructed, so the exporter emitted HELP-only stubs.
# homelab-frontend#63 fixed exactly that — the wrapper is now
# ResponseWriterWrapper, constructed by NewResponseWriterWrapper
# (internal/proxy/llm_metrics.go:38) and installed on the streaming proxy path.
# The "dead code" note that used to live in this header is superseded.
2026-09-29 22:47:38 +08:00
#
# Panels backed by queue_full_requests_total, warmup_requests_delayed_total and
2026-09-30 19:49:05 +08:00
# vllm_prefix_cache_hit_tokens_total also work (3 series, one per replica).
#
2026-09-29 22:11:28 +08:00
#
# All metric names below were verified against a live scrape, not assumed:
# vLLM 0.11.0 exposes prefix_cache_hits_total / prefix_cache_queries_total,
# NOT cache_query_hit_total / cache_query_miss_total.
def build_llm_observability ():
def hq ( metric , q , by = "le" ):
return (
f "histogram_quantile( { q } , sum(rate( { metric } _bucket[5m])) by ( { by } , model_name))" ,
f "p { int ( q * 100 ) } " ,
)
def hq_gw ( metric , q ):
return (
f "histogram_quantile( { q } , sum(rate( { metric } _bucket[5m])) by (le, model))" ,
f "p { int ( q * 100 ) } · {{{{ model }}}} " ,
)
def avg_of ( metric ):
# mean = rate(_sum) / rate(_count) — valid for cumulative histograms
return f "rate( { metric } _sum[5m]) / clamp_min(rate( { metric } _count[5m]), 0.001)"
panels = []
# ── TTFT ────────────────────────────────────────────────────────────────
panels . append ( row ( 100 , "Latency — Time To First Token" , 0 , [
stat_panel ( 101 , "TTFT p95 (gateway)" ,
"histogram_quantile(0.95, sum(rate(llm_ttft_seconds_bucket[5m])) by (le))" ,
0 , 1 , w = 4 , h = 4 , unit = "s" ),
stat_panel ( 102 , "TTFT p95 (vLLM)" ,
"histogram_quantile(0.95, sum(rate(vllm:time_to_first_token_seconds_bucket[5m])) by (le))" ,
4 , 1 , w = 4 , h = 4 , unit = "s" ),
stat_panel ( 103 , "KV cache in use" ,
"max(vllm:kv_cache_usage_perc)" ,
8 , 1 , w = 4 , h = 4 , unit = "percentunit" ),
stat_panel ( 104 , "Requests running" ,
"sum(vllm:num_requests_running)" ,
12 , 1 , w = 4 , h = 4 , unit = "short" ),
stat_panel ( 105 , "Requests waiting" ,
"sum(vllm:num_requests_waiting)" ,
16 , 1 , w = 4 , h = 4 , unit = "short" ),
stat_panel ( 106 , "Preemptions (5m)" ,
"increase(vllm:num_preemptions_total[5m])" ,
20 , 1 , w = 4 , h = 4 , unit = "short" ),
ts_panel ( 110 , "TTFT percentile by model (gateway)" ,
[ hq_gw ( "llm_ttft_seconds" , 0.50 ),
hq_gw ( "llm_ttft_seconds" , 0.95 ),
hq_gw ( "llm_ttft_seconds" , 0.99 )],
0 , 2 , w = 12 , h = 8 , unit = "s" ),
ts_panel ( 111 , "TTFT percentile — vLLM reasoning" ,
[ hq ( "vllm:time_to_first_token_seconds" , 0.50 ),
hq ( "vllm:time_to_first_token_seconds" , 0.95 ),
hq ( "vllm:time_to_first_token_seconds" , 0.99 )],
12 , 2 , w = 12 , h = 8 , unit = "s" ),
ts_panel ( 112 , "Prefill vs decode (mean)" ,
[( avg_of ( "vllm:request_prefill_time_seconds" ), "prefill" ),
( avg_of ( "vllm:request_decode_time_seconds" ), "decode" )],
0 , 3 , w = 12 , h = 8 , unit = "s" ),
ts_panel ( 113 , "Queue wait before scheduling (mean)" ,
[( avg_of ( "vllm:request_queue_time_seconds" ), "queue" )],
12 , 3 , w = 12 , h = 8 , unit = "s" ),
]))
# ── Inter-token latency ─────────────────────────────────────────────────
panels . append ( row ( 120 , "Latency — Inter-Token" , 4 , [
ts_panel ( 121 , "Inter-token latency by model (gateway)" ,
[ hq_gw ( "llm_itl_seconds" , 0.50 ),
hq_gw ( "llm_itl_seconds" , 0.95 ),
hq_gw ( "llm_itl_seconds" , 0.99 )],
0 , 5 , w = 12 , h = 8 , unit = "s" ),
ts_panel ( 122 , "Inter-token latency — vLLM reasoning" ,
[ hq ( "vllm:inter_token_latency_seconds" , 0.50 ),
hq ( "vllm:inter_token_latency_seconds" , 0.95 ),
hq ( "vllm:inter_token_latency_seconds" , 0.99 )],
12 , 5 , w = 12 , h = 8 , unit = "s" ),
ts_panel ( 123 , "Decode throughput (tokens/s per request)" ,
[( f "1 / clamp_min( { avg_of ( 'vllm:time_per_output_token_seconds' ) } , 0.0001)" , "tok/s" )],
0 , 6 , w = 12 , h = 8 , unit = "short" ),
ts_panel ( 124 , "End-to-end request latency p50/p95/p99" ,
[ hq ( "vllm:e2e_request_latency_seconds" , 0.50 ),
hq ( "vllm:e2e_request_latency_seconds" , 0.95 ),
hq ( "vllm:e2e_request_latency_seconds" , 0.99 )],
12 , 6 , w = 12 , h = 8 , unit = "s" ),
]))
# ── Batching / scheduling ───────────────────────────────────────────────
panels . append ( row ( 130 , "Batching & Scheduling" , 7 , [
ts_panel ( 131 , "vLLM running vs waiting requests (batch depth)" ,
[( "sum(vllm:num_requests_running)" , "running (in batch)" ),
( "sum(vllm:num_requests_waiting)" , "waiting (queued)" )],
0 , 8 , w = 12 , h = 8 , unit = "short" ),
ts_panel ( 132 , "Preemption rate (KV cache eviction pressure)" ,
[( "rate(vllm:num_preemptions_total[5m])" , "preemptions/s" )],
12 , 8 , w = 12 , h = 8 , unit = "short" ),
ts_panel ( 133 , "TEI batch size (embeddings / reranker)" ,
[( "avg(te_batch_next_size_sum / clamp_min(te_batch_next_size_count, 1)) by (app)" ,
"{{app}}" )],
0 , 9 , w = 12 , h = 8 , unit = "short" ),
ts_panel ( 134 , "TEI queue depth" ,
[( "max(te_queue_size) by (app)" , "{{app}}" )],
12 , 9 , w = 12 , h = 8 , unit = "short" ),
]))
# ── KV cache ────────────────────────────────────────────────────────────
panels . append ( row ( 140 , "KV Cache" , 10 , [
ts_panel ( 141 , "KV cache utilisation (GPU memory pressure)" ,
[( "max(vllm:kv_cache_usage_perc) by (model_name)" , "{{model_name}}" )],
0 , 11 , w = 12 , h = 8 , unit = "percentunit" ),
ts_panel ( 142 , "Prefix cache hit rate (CAG effectiveness)" ,
[( "vllm:prefix_cache_hit_rate" , "{{model_name}}" )],
12 , 11 , w = 12 , h = 8 , unit = "percentunit" ),
ts_panel ( 143 , "Prefix cache hits vs queries" ,
[( "rate(vllm:prefix_cache_hits_total[5m])" , "hits/s" ),
( "rate(vllm:prefix_cache_queries_total[5m])" , "queries/s" )],
0 , 12 , w = 12 , h = 8 , unit = "short" ),
ts_panel ( 144 , "Prefix cache hit tokens (gateway-observed, all models)" ,
[( "rate(vllm_prefix_cache_hit_tokens_total[5m])" , "hit tokens/s" )],
12 , 12 , w = 12 , h = 8 , unit = "short" ),
]))
# ── Throughput & health ─────────────────────────────────────────────────
panels . append ( row ( 150 , "Throughput & Health" , 13 , [
ts_panel ( 151 , "Token throughput" ,
[( "sum(rate(vllm:prompt_tokens_total[5m]))" , "prompt tok/s" ),
( "sum(rate(vllm:generation_tokens_total[5m]))" , "generation tok/s" ),
( "rate(llm_tokens_total[5m])" , "gateway SSE chunks/s (all models)" )],
0 , 14 , w = 12 , h = 8 , unit = "short" ),
ts_panel ( 152 , "Successful requests/s" ,
[( "sum(rate(vllm:request_success_total[5m]))" , "vLLM" ),
( "sum(rate(te_request_count[5m]))" , "TEI" )],
12 , 14 , w = 12 , h = 8 , unit = "short" ),
ts_panel ( 153 , "Backpressure — queue-full rejects" ,
[( "rate(queue_full_requests_total[5m])" , "rejected/s" )],
0 , 15 , w = 8 , h = 8 , unit = "short" ),
ts_panel ( 154 , "KV warmup requests delayed" ,
[( "rate(warmup_requests_delayed_total[5m])" , "delayed/s" )],
8 , 15 , w = 8 , h = 8 , unit = "short" ),
ts_panel ( 155 , "Embedding / rerank latency p95" ,
[( "histogram_quantile(0.95, sum(rate(te_embed_duration_bucket[5m])) by (le, app))" ,
"{{app}} embed" ),
( "histogram_quantile(0.95, sum(rate(te_embed_queue_duration_bucket[5m])) by (le, app))" ,
"{{app}} queue" )],
16 , 15 , w = 8 , h = 8 , unit = "s" ),
]))
2026-09-30 19:49:05 +08:00
# ── Admission control ───────────────────────────────────────────────────
# These four panels read the gateway's admission gate, which is the
# companion change in homelab-frontend#64. That emitter is NOT deployed
# yet (verified against the live /metrics endpoint: 0 series matching
# ^(admission_|observer_|engine_state_)), so they render "No data" until
# #64 ships. That is expected, and each panel carries a description
# saying so — an unexplained blank panel would read as a broken
# dashboard, which is exactly the failure mode this row exists to catch.
panels . append ( row ( 160 , "Admission Control" , 16 , [
ts_panel ( 161 , "Admission parked (5m)" ,
[( "increase(admission_parked_total[5m])" , "parked" )],
0 , 17 , w = 8 , h = 8 , unit = "short" ,
description = "Requests the gateway held back rather than admitting. "
"Emitted by the admission gate (homelab-frontend#64); "
"empty until that change is deployed." ),
ts_panel ( 162 , "Admission rejected by reason" ,
[( "sum by (reason) (increase(admission_rejected_total[5m]))" , "{{reason}}" )],
8 , 17 , w = 8 , h = 8 , unit = "short" ,
description = "Admission rejections split by the gate's own reason label. "
"Emitted by homelab-frontend#64; empty until deployed." ),
ts_panel ( 163 , "Engine state stale" ,
[( "increase(engine_state_stale_total[5m])" , "stale" )],
16 , 17 , w = 8 , h = 8 , unit = "short" ,
description = "Times the gateway's view of the engine gauges went stale. "
"The gate fails OPEN when this happens, so any non-zero rate "
"means admission is silently degraded to admit-everything. "
"Emitted by homelab-frontend#64; empty until deployed." ),
ts_panel ( 164 , "Gateway view vs engine truth" ,
[( "max(vllm_requests_running)" , "gateway: running" ),
( "sum(vllm:num_requests_running)" , "engine: running" ),
( "max(vllm_kv_usage_perc)" , "gateway: kv (fraction) [same as engine if healthy]" ),
( "max(vllm:kv_cache_usage_perc)" , "engine: kv (fraction)" )],
0 , 18 , w = 24 , h = 8 , unit = "short" ,
description = "The gateway's own reading of the engine against the engine's "
"self-reported value. A disagreement between a pair is how a "
"stale observer or a mistuned admission target shows itself, "
"and nothing else in this dashboard reveals it. The two "
"gateway series (vllm_requests_running, vllm_kv_usage_perc) "
"ship with homelab-frontend#64 and are absent until it is "
"deployed — until then this panel shows the engine's pair only." ),
]))
2026-09-29 22:11:28 +08:00
return {
"title" : "LLM Observability" ,
"uid" : "llm-observability" ,
"schemaVersion" : 39 ,
"timezone" : "browser" ,
"time" : { "from" : "now-6h" , "to" : "now" },
"refresh" : "30s" ,
"tags" : [ "llm" , "vllm" , "tei" , "gpu" , "observability" ],
"panels" : panels ,
}
2026-09-04 22:31:51 -07:00
# ============================================================================
# Generate
# ============================================================================
print ( "Generating dashboards..." )
# Dashboard 1
write_dashboard ( "cluster-infrastructure.yaml" , build_cluster_infrastructure (), "Infrastructure" )
# Dashboard 3
write_dashboard ( "api-gateway.yaml" , build_api_gateway (), "API" )
2026-09-29 22:11:28 +08:00
# Dashboard 4
write_dashboard ( "llm-observability.yaml" , build_llm_observability (), "LLM" )
2026-09-04 22:31:51 -07:00
print ( "Done." )