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`
${this.tightMetric(row.name)} |
${row.metrics.map(
(v) => html`
${this.tightLabel(v.labels)} |
${this.valueDisplay(row, v)} |
`
)}
|
${rowNum == 0
? html`
|
`
: ""}
`
)}
`;
}
}