Mercurial > code > home > repos > light9
comparison web/metrics/StatsLine.ts @ 2376:4556eebe5d73
topdir reorgs; let pdm have its src/ dir; separate vite area from light9/
author | drewp@bigasterisk.com |
---|---|
date | Sun, 12 May 2024 19:02:10 -0700 |
parents | light9/web/metrics/StatsLine.ts@623836db99af |
children |
comparison
equal
deleted
inserted
replaced
2375:623836db99af | 2376:4556eebe5d73 |
---|---|
1 import { css, html, LitElement, TemplateResult } from "lit"; | |
2 import { customElement, property } from "lit/decorators.js"; | |
3 export { StatsProcess } from "./StatsProcess"; | |
4 import parsePrometheusTextFormat from "parse-prometheus-text-format"; | |
5 import debug from "debug"; | |
6 import { clamp } from "../floating_color_picker"; | |
7 const log = debug("home"); | |
8 | |
9 interface Value { | |
10 labels: { string: string }; | |
11 value?: string; | |
12 count?: number; | |
13 sum?: number; | |
14 buckets?: { [value: string]: string }; | |
15 } | |
16 interface Metric { | |
17 name: string; | |
18 help: string; | |
19 type: "GAUGE" | "SUMMARY" | "COUNTER" | "HISTOGRAM" | "UNTYPED"; | |
20 metrics: Value[]; | |
21 } | |
22 type Metrics = Metric[]; | |
23 | |
24 function nonBoring(m: Metric) { | |
25 return ( | |
26 !m.name.endsWith("_created") && // | |
27 !m.name.startsWith("python_gc_") && | |
28 m.name != "python_info" && | |
29 m.name != "process_max_fds" && | |
30 m.name != "process_virtual_memory_bytes" && | |
31 m.name != "process_resident_memory_bytes" && | |
32 m.name != "process_start_time_seconds" && | |
33 m.name != "process_cpu_seconds_total" | |
34 ); | |
35 } | |
36 | |
37 @customElement("stats-line") | |
38 export class StatsLine extends LitElement { | |
39 @property() name = "?"; | |
40 @property() stats: Metrics = []; | |
41 | |
42 prevCpuNow = 0; | |
43 prevCpuTotal = 0; | |
44 @property() cpu = 0; | |
45 @property() mem = 0; | |
46 | |
47 updated(changedProperties: any) { | |
48 changedProperties.forEach((oldValue: any, propName: string) => { | |
49 if (propName == "name") { | |
50 const reload = () => { | |
51 fetch("/service/" + this.name + "/metrics").then((resp) => { | |
52 if (resp.ok) { | |
53 resp | |
54 .text() | |
55 .then((msg) => { | |
56 this.stats = parsePrometheusTextFormat(msg) as Metrics; | |
57 this.extractProcessStats(this.stats); | |
58 setTimeout(reload, 1000); | |
59 }) | |
60 .catch((err) => { | |
61 log(`${this.name} failing`, err); | |
62 setTimeout(reload, 1000); | |
63 }); | |
64 } else { | |
65 if (resp.status == 502) { | |
66 setTimeout(reload, 5000); | |
67 } | |
68 // 404: likely not mapped to a responding server | |
69 } | |
70 }); | |
71 }; | |
72 reload(); | |
73 } | |
74 }); | |
75 } | |
76 extractProcessStats(stats: Metrics) { | |
77 stats.forEach((row: Metric) => { | |
78 if (row.name == "process_resident_memory_bytes") { | |
79 this.mem = parseFloat(row.metrics[0].value!) / 1024 / 1024; | |
80 } | |
81 if (row.name == "process_cpu_seconds_total") { | |
82 const now = Date.now() / 1000; | |
83 const cpuSecondsTotal = parseFloat(row.metrics[0].value!); | |
84 this.cpu = (cpuSecondsTotal - this.prevCpuTotal) / (now - this.prevCpuNow); | |
85 this.prevCpuTotal = cpuSecondsTotal; | |
86 this.prevCpuNow = now; | |
87 } | |
88 }); | |
89 } | |
90 | |
91 static styles = [ | |
92 css` | |
93 :host { | |
94 border: 2px solid #46a79f; | |
95 display: inline-block; | |
96 } | |
97 table { | |
98 border-collapse: collapse; | |
99 background: #000; | |
100 color: #ccc; | |
101 font-family: sans-serif; | |
102 } | |
103 th, | |
104 td { | |
105 outline: 1px solid #000; | |
106 } | |
107 th { | |
108 padding: 2px 4px; | |
109 background: #2f2f2f; | |
110 text-align: left; | |
111 } | |
112 td { | |
113 padding: 0; | |
114 vertical-align: top; | |
115 text-align: center; | |
116 } | |
117 td.val { | |
118 padding: 2px 4px; | |
119 background: #3b5651; | |
120 } | |
121 .recents { | |
122 display: flex; | |
123 align-items: flex-end; | |
124 height: 30px; | |
125 } | |
126 .recents > div { | |
127 width: 3px; | |
128 background: red; | |
129 border-right: 1px solid black; | |
130 } | |
131 .bigInt { | |
132 min-width: 6em; | |
133 } | |
134 `, | |
135 ]; | |
136 | |
137 tdWrap(content: TemplateResult): TemplateResult { | |
138 return html`<td>${content}</td>`; | |
139 } | |
140 | |
141 recents(d: any, path: string[]): TemplateResult { | |
142 const hi = Math.max.apply(null, d.recents); | |
143 const scl = 30 / hi; | |
144 | |
145 const bar = (y: number) => { | |
146 let color; | |
147 if (y < d.average) { | |
148 color = "#6a6aff"; | |
149 } else { | |
150 color = "#d09e4c"; | |
151 } | |
152 return html`<div class="bar" style="height: ${y * scl}px; background: ${color};"></div>`; | |
153 }; | |
154 return html`<td> | |
155 <div class="recents">${d.recents.map(bar)}</div> | |
156 <div>avg=${d.average.toPrecision(3)}</div> | |
157 </td>`; | |
158 } | |
159 | |
160 table(d: Metrics, path: string[]): TemplateResult { | |
161 const byName = new Map<string, Metric>(); | |
162 d.forEach((row) => { | |
163 byName.set(row.name, row); | |
164 }); | |
165 let cols = d.map((row) => row.name); | |
166 cols.sort(); | |
167 | |
168 if (path.length == 0) { | |
169 ["webServer", "process"].forEach((earlyKey) => { | |
170 let i = cols.indexOf(earlyKey); | |
171 if (i != -1) { | |
172 cols = [earlyKey].concat(cols.slice(0, i), cols.slice(i + 1)); | |
173 } | |
174 }); | |
175 } | |
176 | |
177 const th = (col: string): TemplateResult => { | |
178 return html`<th>${col}</th>`; | |
179 }; | |
180 const td = (col: string): TemplateResult => { | |
181 const cell = byName.get(col)!; | |
182 return html`${this.drawLevel(cell, path.concat(col))}`; | |
183 }; | |
184 return html` <table> | |
185 <tr> | |
186 ${cols.map(th)} | |
187 </tr> | |
188 <tr> | |
189 ${cols.map(td)} | |
190 </tr> | |
191 </table>`; | |
192 } | |
193 | |
194 drawLevel(d: Metric, path: string[]) { | |
195 return html`[NEW ${JSON.stringify(d)} ${path}]`; | |
196 } | |
197 | |
198 valueDisplay(m: Metric, v: Value): TemplateResult { | |
199 if (m.type == "GAUGE") { | |
200 return html`${v.value}`; | |
201 } else if (m.type == "COUNTER") { | |
202 return html`${v.value}`; | |
203 } else if (m.type == "HISTOGRAM") { | |
204 return this.histoDisplay(v.buckets!); | |
205 } else if (m.type == "UNTYPED") { | |
206 return html`${v.value}`; | |
207 } else if (m.type == "SUMMARY") { | |
208 if (!v.count) { | |
209 return html`err: summary without count`; | |
210 } | |
211 return html`n=${v.count} percall=${((v.count && v.sum ? v.sum / v.count : 0) * 1000).toPrecision(3)}ms`; | |
212 } else { | |
213 throw m.type; | |
214 } | |
215 } | |
216 | |
217 private histoDisplay(b: { [value: string]: string }) { | |
218 const lines: TemplateResult[] = []; | |
219 let firstLevel; | |
220 let lastLevel; | |
221 let prev = 0; | |
222 | |
223 let maxDelta = 0; | |
224 for (let level in b) { | |
225 if (firstLevel === undefined) firstLevel = level; | |
226 lastLevel = level; | |
227 let count = parseFloat(b[level]); | |
228 let delta = count - prev; | |
229 prev = count; | |
230 if (delta > maxDelta) maxDelta = delta; | |
231 } | |
232 prev = 0; | |
233 const maxBarH = 30; | |
234 for (let level in b) { | |
235 let count = parseFloat(b[level]); | |
236 let delta = count - prev; | |
237 prev = count; | |
238 let levelf = parseFloat(level); | |
239 const h = clamp((delta / maxDelta) * maxBarH, 1, maxBarH); | |
240 lines.push( | |
241 html`<div | |
242 title="bucket=${level} count=${count}" | |
243 style="background: yellow; margin-right: 1px; width: 8px; height: ${h}px; display: inline-block" | |
244 ></div>` | |
245 ); | |
246 } | |
247 return html`${firstLevel} ${lines} ${lastLevel}`; | |
248 } | |
249 | |
250 tightLabel(labs: { [key: string]: string }): string { | |
251 const d: { [key: string]: string } = {}; | |
252 for (let k in labs) { | |
253 if (k == "app_name") continue; | |
254 if (k == "output") continue; | |
255 if (k == "status_code" && labs[k] == "200") continue; | |
256 d[k] = labs[k]; | |
257 } | |
258 const ret = JSON.stringify(d); | |
259 return ret == "{}" ? "" : ret; | |
260 } | |
261 tightMetric(name: string): string { | |
262 return name.replace("starlette", "⭐").replace("_request", "_req").replace("_duration", "_dur").replace("_seconds", "_s"); | |
263 } | |
264 render() { | |
265 const now = Date.now() / 1000; | |
266 | |
267 const displayedStats = this.stats.filter(nonBoring); | |
268 return html` | |
269 <div> | |
270 <table> | |
271 ${displayedStats.map( | |
272 (row, rowNum) => html` | |
273 <tr> | |
274 <th>${this.tightMetric(row.name)}</th> | |
275 <td> | |
276 <table> | |
277 ${row.metrics.map( | |
278 (v) => html` | |
279 <tr> | |
280 <td>${this.tightLabel(v.labels)}</td> | |
281 <td>${this.valueDisplay(row, v)}</td> | |
282 </tr> | |
283 ` | |
284 )} | |
285 </table> | |
286 </td> | |
287 ${rowNum == 0 | |
288 ? html` | |
289 <td rowspan="${displayedStats.length}"> | |
290 <stats-process id="proc" cpu="${this.cpu}" mem="${this.mem}"></stats-process> | |
291 </td> | |
292 ` | |
293 : ""} | |
294 </tr> | |
295 ` | |
296 )} | |
297 </table> | |
298 </div> | |
299 `; | |
300 } | |
301 } |