diff --git a/README.md b/README.md
index 34cb530..3c2f38c 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/web/index.html b/web/index.html
index cce5201..8e4ed97 100644
--- a/web/index.html
+++ b/web/index.html
@@ -5,8 +5,8 @@
-
-
+
+
diff --git a/web/index.css b/web/portal.css
similarity index 100%
rename from web/index.css
rename to web/portal.css
diff --git a/web/portal.json b/web/portal.json
index aa9dc6f..7c93060 100644
--- a/web/portal.json
+++ b/web/portal.json
@@ -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",
diff --git a/web/index.mjs b/web/portal.mjs
similarity index 86%
rename from web/index.mjs
rename to web/portal.mjs
index f7a04aa..0896112 100644
--- a/web/index.mjs
+++ b/web/portal.mjs
@@ -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()
}
}
diff --git a/web/stat/stat.html b/web/stat.html
similarity index 54%
rename from web/stat/stat.html
rename to web/stat.html
index 3cda6bf..89d9bcf 100644
--- a/web/stat/stat.html
+++ b/web/stat.html
@@ -5,15 +5,22 @@
-
-
-
+
+
+
diff --git a/web/stat.mjs b/web/stat.mjs
new file mode 100644
index 0000000..952f28c
--- /dev/null
+++ b/web/stat.mjs
@@ -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
+ */
+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,
+}
\ No newline at end of file
diff --git a/web/stat/stat.mjs b/web/stat/stat.mjs
deleted file mode 100644
index e8fb62c..0000000
--- a/web/stat/stat.mjs
+++ /dev/null
@@ -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()
-}
diff --git a/web/stat/webstat.mjs b/web/stat/webstat.mjs
deleted file mode 100644
index 2d781a7..0000000
--- a/web/stat/webstat.mjs
+++ /dev/null
@@ -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,
-}
\ No newline at end of file