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 }