Mercurial > code > home > repos > light9
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/web/metrics/StatsLine.ts Sun May 12 19:02:10 2024 -0700 @@ -0,0 +1,301 @@ +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +export { StatsProcess } from "./StatsProcess"; +import parsePrometheusTextFormat from "parse-prometheus-text-format"; +import debug from "debug"; +import { clamp } from "../floating_color_picker"; +const log = debug("home"); + +interface Value { + labels: { string: string }; + value?: string; + count?: number; + sum?: number; + buckets?: { [value: string]: string }; +} +interface Metric { + name: string; + help: string; + type: "GAUGE" | "SUMMARY" | "COUNTER" | "HISTOGRAM" | "UNTYPED"; + metrics: Value[]; +} +type Metrics = Metric[]; + +function nonBoring(m: Metric) { + return ( + !m.name.endsWith("_created") && // + !m.name.startsWith("python_gc_") && + m.name != "python_info" && + m.name != "process_max_fds" && + m.name != "process_virtual_memory_bytes" && + m.name != "process_resident_memory_bytes" && + m.name != "process_start_time_seconds" && + m.name != "process_cpu_seconds_total" + ); +} + +@customElement("stats-line") +export class StatsLine extends LitElement { + @property() name = "?"; + @property() stats: Metrics = []; + + prevCpuNow = 0; + prevCpuTotal = 0; + @property() cpu = 0; + @property() mem = 0; + + updated(changedProperties: any) { + changedProperties.forEach((oldValue: any, propName: string) => { + if (propName == "name") { + const reload = () => { + fetch("/service/" + this.name + "/metrics").then((resp) => { + if (resp.ok) { + resp + .text() + .then((msg) => { + this.stats = parsePrometheusTextFormat(msg) as Metrics; + this.extractProcessStats(this.stats); + setTimeout(reload, 1000); + }) + .catch((err) => { + log(`${this.name} failing`, err); + setTimeout(reload, 1000); + }); + } else { + if (resp.status == 502) { + setTimeout(reload, 5000); + } + // 404: likely not mapped to a responding server + } + }); + }; + reload(); + } + }); + } + extractProcessStats(stats: Metrics) { + stats.forEach((row: Metric) => { + if (row.name == "process_resident_memory_bytes") { + this.mem = parseFloat(row.metrics[0].value!) / 1024 / 1024; + } + if (row.name == "process_cpu_seconds_total") { + const now = Date.now() / 1000; + const cpuSecondsTotal = parseFloat(row.metrics[0].value!); + this.cpu = (cpuSecondsTotal - this.prevCpuTotal) / (now - this.prevCpuNow); + this.prevCpuTotal = cpuSecondsTotal; + this.prevCpuNow = now; + } + }); + } + + static styles = [ + css` + :host { + border: 2px solid #46a79f; + display: inline-block; + } + table { + border-collapse: collapse; + background: #000; + color: #ccc; + font-family: sans-serif; + } + th, + td { + outline: 1px solid #000; + } + th { + padding: 2px 4px; + background: #2f2f2f; + text-align: left; + } + td { + padding: 0; + vertical-align: top; + text-align: center; + } + td.val { + padding: 2px 4px; + background: #3b5651; + } + .recents { + display: flex; + align-items: flex-end; + height: 30px; + } + .recents > div { + width: 3px; + background: red; + border-right: 1px solid black; + } + .bigInt { + min-width: 6em; + } + `, + ]; + + tdWrap(content: TemplateResult): TemplateResult { + return html`<td>${content}</td>`; + } + + recents(d: any, path: string[]): TemplateResult { + const hi = Math.max.apply(null, d.recents); + const scl = 30 / hi; + + const bar = (y: number) => { + let color; + if (y < d.average) { + color = "#6a6aff"; + } else { + color = "#d09e4c"; + } + return html`<div class="bar" style="height: ${y * scl}px; background: ${color};"></div>`; + }; + return html`<td> + <div class="recents">${d.recents.map(bar)}</div> + <div>avg=${d.average.toPrecision(3)}</div> + </td>`; + } + + table(d: Metrics, path: string[]): TemplateResult { + const byName = new Map<string, Metric>(); + d.forEach((row) => { + byName.set(row.name, row); + }); + let cols = d.map((row) => row.name); + cols.sort(); + + if (path.length == 0) { + ["webServer", "process"].forEach((earlyKey) => { + let i = cols.indexOf(earlyKey); + if (i != -1) { + cols = [earlyKey].concat(cols.slice(0, i), cols.slice(i + 1)); + } + }); + } + + const th = (col: string): TemplateResult => { + return html`<th>${col}</th>`; + }; + const td = (col: string): TemplateResult => { + const cell = byName.get(col)!; + return html`${this.drawLevel(cell, path.concat(col))}`; + }; + return html` <table> + <tr> + ${cols.map(th)} + </tr> + <tr> + ${cols.map(td)} + </tr> + </table>`; + } + + drawLevel(d: Metric, path: string[]) { + return html`[NEW ${JSON.stringify(d)} ${path}]`; + } + + valueDisplay(m: Metric, v: Value): TemplateResult { + if (m.type == "GAUGE") { + return html`${v.value}`; + } else if (m.type == "COUNTER") { + return html`${v.value}`; + } else if (m.type == "HISTOGRAM") { + return this.histoDisplay(v.buckets!); + } else if (m.type == "UNTYPED") { + return html`${v.value}`; + } else if (m.type == "SUMMARY") { + if (!v.count) { + return html`err: summary without count`; + } + return html`n=${v.count} percall=${((v.count && v.sum ? v.sum / v.count : 0) * 1000).toPrecision(3)}ms`; + } else { + throw m.type; + } + } + + private histoDisplay(b: { [value: string]: string }) { + const lines: TemplateResult[] = []; + let firstLevel; + let lastLevel; + let prev = 0; + + let maxDelta = 0; + for (let level in b) { + if (firstLevel === undefined) firstLevel = level; + lastLevel = level; + let count = parseFloat(b[level]); + let delta = count - prev; + prev = count; + if (delta > maxDelta) maxDelta = delta; + } + prev = 0; + const maxBarH = 30; + for (let level in b) { + let count = parseFloat(b[level]); + let delta = count - prev; + prev = count; + let levelf = parseFloat(level); + const h = clamp((delta / maxDelta) * maxBarH, 1, maxBarH); + lines.push( + html`<div + title="bucket=${level} count=${count}" + style="background: yellow; margin-right: 1px; width: 8px; height: ${h}px; display: inline-block" + ></div>` + ); + } + return html`${firstLevel} ${lines} ${lastLevel}`; + } + + tightLabel(labs: { [key: string]: string }): string { + const d: { [key: string]: string } = {}; + for (let k in labs) { + if (k == "app_name") continue; + if (k == "output") continue; + if (k == "status_code" && labs[k] == "200") continue; + d[k] = labs[k]; + } + const ret = JSON.stringify(d); + return ret == "{}" ? "" : ret; + } + tightMetric(name: string): string { + return name.replace("starlette", "⭐").replace("_request", "_req").replace("_duration", "_dur").replace("_seconds", "_s"); + } + render() { + const now = Date.now() / 1000; + + const displayedStats = this.stats.filter(nonBoring); + return html` + <div> + <table> + ${displayedStats.map( + (row, rowNum) => html` + <tr> + <th>${this.tightMetric(row.name)}</th> + <td> + <table> + ${row.metrics.map( + (v) => html` + <tr> + <td>${this.tightLabel(v.labels)}</td> + <td>${this.valueDisplay(row, v)}</td> + </tr> + ` + )} + </table> + </td> + ${rowNum == 0 + ? html` + <td rowspan="${displayedStats.length}"> + <stats-process id="proc" cpu="${this.cpu}" mem="${this.mem}"></stats-process> + </td> + ` + : ""} + </tr> + ` + )} + </table> + </div> + `; + } +}