Polished up and ready to distribute, maybe

This commit is contained in:
Neale Pickett 2023-03-11 13:26:33 -07:00
parent 2faa8f9d8c
commit 39ef8955e6
9 changed files with 324 additions and 275 deletions

View File

@ -1,6 +1,52 @@
# WebStat
# Homelab Portal
This is a simple service to provide `/proc/stat` on demand.
Some JavaScript parses it and graphs it.
This repository provide some simple scaffolding to build a landing page for your homelab.
You can link to all your stuff,
including pretty icons,
and your apps will show up under the same top URL,
like they all work together somehow.
Essentially, this is a browser version of something like top or btop.
There are lots of other things like this around.
Probably the shiniest is called "Heimdall".
I made this one because I didn't want to run yet another database
or a PHP server
when I am perfectly happy editing a JSON file
and having my browser do all the work.
# portal.json
You define all your services in `/web/portal.json`.
It works like this:
```json
[
{
"name": "My Thing",
"href": "https://myhost.example.org/path/to/my/thing/",
"icon": "https://myhost.example.org/path/to/my/thing/icons.png"
},
{
"name": "Another thing",
"href": "https://example.net/",
"target": "_blank"
},
{
"name": "Stat",
"href": "/stat.html",
"app": "stat"
}
]
```
Some notes:
* `"target": "_blank"` will cause clicks to open in a new tab
* `"app": "stat"` makes the icon be the built-in CPU stats pie chart
# Other uses for this
Since the portal stuff is all static files,
you could just not use any of that,
and instead use this as a service to provide CPU stats.
Just open `/stats.html` instead of `/`.
Or write your own ECMAScript code that imports `stat.mjs`.

View File

@ -5,8 +5,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="deergrove.png">
<link rel="stylesheet" href="index.css">
<script src="index.mjs" type="module"></script>
<link rel="stylesheet" href="portal.css">
<script src="portal.mjs" type="module"></script>
</head>
<body>
<nav>

View File

@ -1,66 +1,18 @@
[
{
"title": "Movies",
"href": "https://deergrove.woozle.org/radarr/",
"icon": "/radarr/Content/Images/logo.svg"
"title": "Woozle",
"href": "https://woozle.org/"
},
{
"title": "Episodes",
"href": "https://deergrove.woozle.org/sonarr/",
"icon": "/sonarr/Content/Images/logo.svg"
},
"title": "Forgejo",
"href": "https://forgejo.org/",
"target": "_blank",
"icon": "https://forgejo.org/favicon.svg"
}
{
"title": "Music",
"href": "https://deergrove.woozle.org/lidarr/",
"icon": "/lidarr/Content/Images/logo.svg"
},
{
"title": "Books",
"href": "https://deergrove.woozle.org/readarr/",
"icon": "/readarr/Content/Images/logo.svg"
},
{
"title": "Media Sucker",
"href": "https://deergrove.woozle.org/sucker/",
"icon": "/sucker/cd-dvd.svg"
},
{
"title": "Searcher",
"href": "https://deergrove.woozle.org/prowlarr/",
"icon": "/prowlarr/Content/Images/logo.png"
},
{
"title": "Usenet",
"href": "https://deergrove.woozle.org/nzbget/",
"icon": "/nzbget/img/favicon-256x256.png"
},
{
"title": "BitTorrent",
"href": "https://deergrove.woozle.org/transmission/web/",
"icon": "/transmission/web/images/webclip-icon.png"
},
{
"title": "3D Printer",
"href": "https://deergrove.woozle.org/octoprint/",
"icon": "/octoprint/static/img/logo.png"
},
{
"title": "Git",
"href": "https://git.woozle.org/",
"icon": "https://git.woozle.org/assets/img/logo.svg",
"target": "_blank"
},
{
"title": "Storage",
"href": "https://drive.woozle.org/",
"icon": "https://drive.woozle.org/storage/public/icons/cloud-folder.png",
"target": "_blank"
},
{
"title": "Genealogy",
"href": "https://ancestry.woozle.org/",
"icon": "https://ancestry.woozle.org/images/arbre_start.png",
"target": "_blank"
"title": "Jellyfin",
"href": "https://jellyfin.org/",
"icon": "https://jellyfin.org/images/icon-transparent.svg"
},
{
"title": "Host Stats",

View File

@ -1,4 +1,4 @@
import * as WebStat from "./stat/webstat.mjs"
import * as Stat from "./stat.mjs"
const Millisecond = 1
const Second = 1000 * Millisecond
@ -6,16 +6,22 @@ const Minute = 60 * Second
class StatApp {
constructor(parent) {
this.stat = new WebStat.Stat()
this.chart = new WebStat.PieChart(parent, this.stat)
this.parent= parent
this.stat = new Stat.Stat()
this.chart = new Stat.PieChart(parent, this.stat)
setInterval(()=>this.update(), 2 * Second)
this.update()
setInterval(()=>this.update(), 2 * Second)
setTimeout(()=>this.update(), 500 * Millisecond)
this.update()
}
async update() {
this.stat.update()
this.chart.update()
// Use non-standard checkVisibility method to avoid a pointless update
if (this.parent.checkVisibility && !this.parent.checkVisibility()) {
return
}
await this.stat.update()
this.chart.update()
}
}

View File

@ -5,15 +5,22 @@
<meta charset="utf-8">
<script src="stat.mjs" type="module"></script>
<style>
#pie {
html, body {
margin: 0;
}
#pie canvas {
max-width: 50px;
position: absolute;
}
#area canvas {
width: 100vw;
height: 99vh;
}
</style>
</head>
<body>
<div id="pie"></div>
<div id="chart"></div>
<body data-autostat>
<div id="pie" class="pie chart"></div>
<div id="area" class="area chart"></div>
</body>
</html>

238
web/stat.mjs Normal file
View File

@ -0,0 +1,238 @@
const τ = Math.PI * 2
const Millisecond = 1
const Second = 1000 * Millisecond
/**
* Stat keeps track of CPU usage since the last update
*/
class Stat {
constructor() {
this.Stat = {}
this.LastStat = {}
}
async update() {
let resp = await fetch("proc/stat")
let stext = await resp.text()
let lines = stext.split("\n")
this.Date = new Date(resp.headers.get("Date"))
this.LastStat = this.Stat
this.Stat = {}
for (let line of lines) {
let parts = line.split(/\s+/)
let key = parts.shift()
let vals = parts.map(Number)
this.Stat[key] = vals
}
}
/**
* Compute CPU use since last update
*
* @param {Number} n CPU to look at. Leave blank for a summary of all CPUs.
* @returns Object with CPU usages (0.0-1.0)
*/
cpu(n="") {
let key = `cpu${n}`
let vals = this.Stat[key]
let prev = this.LastStat[key]
if (!vals || !prev) {
return {}
}
let total = vals.reduce((a,b)=>a+b) - prev.reduce((a,b)=>a+b)
return {
user: (vals[0] - prev[0]) / total,
nice: (vals[1] - prev[1]) / total,
sys: (vals[2] - prev[2]) / total,
idle: (vals[3] - prev[3]) / total,
wait: (vals[4] - prev[4]) / total,
irq: (vals[5] - prev[5]) / total,
softirq: (vals[6] - prev[6]) / total,
steal: (vals[7] - prev[7]) / total,
guest: (vals[8] - prev[8]) / total,
guestNice: (vals[9] - prev[9]) / total,
}
}
}
/**
* Chart defines common functionality for any chart
*/
class Chart {
constructor(element, stat) {
this.stat = stat
this.canvas = element.appendChild(document.createElement("canvas"))
this.ctx = this.canvas.getContext("2d")
this.colors = ["SkyBlue", "SeaGreen", "Gold", "Tomato"]
this.labels = ["u", "n", "s", "w"]
}
cpu() {
let cpu = this.stat.cpu()
return [cpu.user, cpu.nice, cpu.sys, cpu.wait]
}
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
}
/**
* PieChart makes a little CPU usage pie chart, with idle time transparent.
*/
class PieChart extends Chart{
/**
*
* @param {Element} element
* @param {Stat} stat
*/
constructor(element, stat) {
super(element, stat)
this.r = 250
this.θ = 0
this.canvas.width = this.r*2
this.canvas.height = this.r*2
}
async update() {
let values = this.cpu()
let θ = -τ/4
this.clear()
this.ctx.save()
this.ctx.translate(this.r, this.r)
this.ctx.font = `bold ${this.r/3}px sans-serif`
for (let i in values) {
let angle = values[i] * τ
this.ctx.fillStyle = this.colors[i]
this.ctx.beginPath()
this.ctx.arc(0, 0, this.r, θ, θ + angle, false)
this.ctx.lineTo(0, 0)
this.ctx.fill()
if (angle > τ/12) {
let mid = θ + angle/2
this.ctx.save()
this.ctx.rotate(mid)
this.ctx.translate(this.r*0.7, 0)
this.ctx.rotate(-mid)
this.ctx.fillStyle = "black"
this.ctx.textBaseline = "middle"
this.ctx.textAlign = "center"
this.ctx.fillText(this.labels[i], 0, 0)
this.ctx.restore()
}
θ += angle
}
this.ctx.restore()
}
}
/**
* Area chart makes a packed, stacked bar chart of historical CPU usage.
*/
class AreaChart extends Chart {
/**
*
* @param {Element} element
* @param {Stat} stat
* @param {Number} historyLen How many historical values to keep
*/
constructor(element, stat, historyLen=300) {
super(element, stat)
this.historyLen = historyLen
this.history = Array(this.historyLen) // Make it fill in from the right
this.canvas.width = 600
this.canvas.height = 500
// Cartesian coordinates
this.ctx.translate(0, this.canvas.height)
this.ctx.scale(1, -1)
}
async update() {
let xStep = this.canvas.width / this.historyLen
let yStep = this.canvas.height / 1
this.history.push(this.cpu())
while (this.history.length > this.historyLen) {
this.history.shift()
}
this.clear()
for (let y = 0; y < this.canvas.height; y += 0.25*this.canvas.height) {
this.ctx.strokeStyle = "silver"
this.ctx.beginPath()
this.ctx.moveTo(0, y)
this.ctx.lineTo(this.canvas.width, y)
this.ctx.stroke()
}
for (let i in this.history) {
let h = this.history[i]
if (!h) {
continue
}
let x = i * xStep
let y = 0
for (let j in h) {
let val = h[j]
this.ctx.beginPath()
this.ctx.fillStyle = this.colors[j]
this.ctx.rect(x, y, xStep, yStep * val)
this.ctx.fill()
y += yStep * val
}
}
}
}
/**
* AutoStat automatically sets up any pie and area charts in the document.
*
* You can enable this automatically by providing <body dataset-autostat>
*/
class AutoStat {
constructor() {
this.stat = new Stat()
this.charts = []
for (let e of document.querySelectorAll(".pie.chart")) {
this.charts.push(new PieChart(e, this.stat))
}
for (let e of document.querySelectorAll(".area.chart")) {
this.charts.push(new AreaChart(e, this.stat))
}
setInterval(()=>this.update(), 2 * Second)
this.update()
setTimeout(()=>this.update(), 500 * Millisecond)
}
async update() {
await this.stat.update()
for (let c of this.charts) {
c.update()
}
}
}
function init() {
if (document.body.dataset.autostat != undefined) {
new AutoStat()
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}
export {
Stat,
PieChart,
AreaChart,
AutoStat,
}

View File

@ -1,32 +0,0 @@
import * as WebStat from "./webstat.mjs"
const Millisecond = 1
const Second = 1000 * Millisecond
const Minute = 60 * Second
class App {
constructor() {
this.stat = new WebStat.Stat()
this.areaChart = new WebStat.AreaChart(document.querySelector("#chart"), this.stat)
this.pieChart = new WebStat.PieChart(document.querySelector("#pie"), this.stat)
setInterval(()=>this.update(), 2 * Second)
}
async update() {
this.stat.update()
this.areaChart.update()
this.pieChart.update()
}
}
function init() {
new App()
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}

View File

@ -1,168 +0,0 @@
const τ = Math.PI * 2
function qpush(arr, val, len) {
arr.push(val)
if (arr.length > len) {
arr.shift()
}
}
class Stat {
constructor() {
this.Stat = {}
this.LastStat = {}
}
async update() {
let resp = await fetch("proc/stat")
let stext = await resp.text()
let lines = stext.split("\n")
this.Date = new Date(resp.headers.get("Date"))
this.LastStat = this.Stat
this.Stat = {}
for (let line of lines) {
let parts = line.split(/\s+/)
let key = parts.shift()
let vals = parts.map(Number)
this.Stat[key] = vals
}
}
cpu(n="") {
let key = `cpu${n}`
let vals = this.Stat[key]
let prev = this.LastStat[key]
if (!vals || !prev) {
return {}
}
let total = vals.reduce((a,b)=>a+b) - prev.reduce((a,b)=>a+b)
return {
user: (vals[0] - prev[0]) / total,
nice: (vals[1] - prev[1]) / total,
sys: (vals[2] - prev[2]) / total,
idle: (vals[3] - prev[3]) / total,
wait: (vals[4] - prev[4]) / total,
irq: (vals[5] - prev[5]) / total,
softirq: (vals[6] - prev[6]) / total,
steal: (vals[7] - prev[7]) / total,
guest: (vals[8] - prev[8]) / total,
guestNice: (vals[9] - prev[9]) / total,
}
}
}
class AreaChart {
constructor(element, stat, width=60) {
this.stat = stat
this.width = width
this.canvas = element.appendChild(document.createElement("canvas"))
this.data ={
labels: [],
datasets: []
}
this.datasets = {}
for (let label of ["user", "nice", "sys", "idle", "wait"]) {
let d = []
this.data.datasets.push({
label: label,
data: d,
})
this.datasets[label] = d
}
this.chart = new Chart(
this.canvas,
{
type: "line",
data: this.data,
options: {
responsive: true,
pointStyle: false,
scales: {
x: {
title: { display: false },
ticks: { display: false },
grid: { display: false },
},
y: {
stacked: true,
ticks: { display: false },
}
}
}
}
)
}
async update() {
let cpu = this.stat.cpu()
qpush(this.data.labels, this.stat.Date, this.width)
qpush(this.datasets.user, cpu.user, this.width)
qpush(this.datasets.nice, cpu.nice, this.width)
qpush(this.datasets.sys, cpu.sys, this.width)
qpush(this.datasets.idle, cpu.idle, this.width)
qpush(this.datasets.wait, cpu.wait, this.width)
this.chart.update()
}
}
class PieChart {
constructor(element, stat) {
this.stat = stat
this.canvas = element.appendChild(document.createElement("canvas"))
this.ctx = this.canvas.getContext("2d")
this.r = 250
this.θ = 0
this.canvas.width = this.r*2
this.canvas.height = this.r*2
}
arc(angle, color) {
this.ctx.fillStyle = color
this.ctx.beginPath()
this.ctx.arc(this.r, this,r, this,r, this.θ, this.θ + angle)
this.ctx.fill()
}
async update() {
let cpu = this.stat.cpu()
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
let colors = ["SkyBlue", "SeaGreen", "Gold", "Tomato"]
let values = [cpu.user, cpu.nice, cpu.sys, cpu.wait]
let labels = ["u", "n", "s", "w"]
let θ = -τ/4
this.ctx.save()
this.ctx.translate(this.r, this.r)
this.ctx.font = `bold ${this.r/3}px sans-serif`
for (let i = 0; i < colors.length; i++) {
let angle = values[i] * τ
this.ctx.fillStyle = colors[i]
this.ctx.beginPath()
this.ctx.arc(0, 0, this.r, θ, θ + angle, false)
this.ctx.lineTo(0, 0)
this.ctx.fill()
if (angle > τ/12) {
let mid = θ + angle/2
this.ctx.save()
this.ctx.rotate(mid)
this.ctx.translate(this.r*0.7, 0)
this.ctx.rotate(-mid)
this.ctx.fillStyle = "black"
this.ctx.textBaseline = "middle"
this.ctx.textAlign = "center"
this.ctx.fillText(labels[i], 0, 0)
this.ctx.restore()
}
θ += angle
}
this.ctx.restore()
}
}
export {
Stat,
PieChart,
}