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`${content}`; } 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`
`; }; return html`
${d.recents.map(bar)}
avg=${d.average.toPrecision(3)}
`; } table(d: Metrics, path: string[]): TemplateResult { const byName = new Map(); 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`${col}`; }; const td = (col: string): TemplateResult => { const cell = byName.get(col)!; return html`${this.drawLevel(cell, path.concat(col))}`; }; return html` ${cols.map(th)} ${cols.map(td)}
`; } 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`
` ); } 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`
${displayedStats.map( (row, rowNum) => html` ${rowNum == 0 ? html` ` : ""} ` )}
${this.tightMetric(row.name)} ${row.metrics.map( (v) => html` ` )}
${this.tightLabel(v.labels)} ${this.valueDisplay(row, v)}
`; } }