Rahix
1e21222705
In the process automation world, there is a trend to move away from colorful user-interfaces, towards more "boring" colorschemes. The argument is about situational awareness - by only using colors to highlight abnormal situations, they become instantly recognizable to the operators. This design philosophy is outlined by the ISA-101 [1] under the name "High Performance HMI". While it covers much more than just colors, I think this is the most important part and the one that is most applicable for our usecase. So let's do a bit of HP HMI - reduce colors usage such that only important information is highlighted. [1]: https://www.isa.org/standards-and-publications/isa-standards/isa-standards-committees/isa101
438 lines
12 KiB
HTML
438 lines
12 KiB
HTML
<!DOCTYPE html>
|
|
<meta charset="utf-8">
|
|
<title>succd</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<link rel="shortcut icon" type="image/png" href="/favicon.png">
|
|
<style>
|
|
body {
|
|
font-size: 12px;
|
|
padding: 2em;
|
|
}
|
|
table {
|
|
font-size: 40px;
|
|
}
|
|
table.status td {
|
|
width: 2em;
|
|
}
|
|
th, td {
|
|
background-color: #e8e8e8;
|
|
padding: 0.4em;
|
|
}
|
|
th {
|
|
font-weight: 100;
|
|
text-align: right;
|
|
font-size: 30px;
|
|
}
|
|
td {
|
|
text-align: left;
|
|
}
|
|
td {
|
|
font-weight: 800;
|
|
}
|
|
h2 {
|
|
font-style: italic;
|
|
font-weight: 100;
|
|
}
|
|
button {
|
|
height: 4.5em;
|
|
padding-left: 1.5em;
|
|
padding-right: 1.5em;
|
|
}
|
|
|
|
td > span {
|
|
padding: 0.2em;
|
|
}
|
|
|
|
.logo {
|
|
float: left;
|
|
margin-right: 2em;
|
|
}
|
|
|
|
.logo > img {
|
|
height: 10em;
|
|
}
|
|
|
|
.graph-container {
|
|
background-color: #e8e8e8;
|
|
text-align: center;
|
|
}
|
|
|
|
.main-grid {
|
|
margin-top: 2em;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
max-width: 160em;
|
|
clear: both;
|
|
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(54em, 1fr));
|
|
column-gap: 2em;
|
|
row-gap: 2em;
|
|
}
|
|
|
|
@media only screen and (max-width: 700px) {
|
|
body {
|
|
font-size: 6px;
|
|
}
|
|
table {
|
|
font-size: 20px;
|
|
}
|
|
th {
|
|
font-size: 15px;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<div class="logo"><img src="/favicon.png" /></div>
|
|
|
|
<h1>succd</h1>
|
|
<h2>nothing more permanent than a temporary solution</h2>
|
|
|
|
<div class="main-grid">
|
|
<table class="status">
|
|
<tr>
|
|
<th>Thresholds</th>
|
|
<th>Rough</th>
|
|
<td id="trough">{{ if .Feedback.RoughReached }}OK{{ else }}NOK{{ end }}</td>
|
|
<th>High</th>
|
|
<td id="thigh">{{ if .Feedback.HighReached }}OK{{ else }}NOK{{ end }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Pumps</th>
|
|
<th>RP</th>
|
|
<td id="rp">{{ if .Pumps.RPOn }}ON{{ else }}OFF{{ end }}</td>
|
|
<th>DP</th>
|
|
<td id="dp">{{ if .Pumps.DPOn }}ON{{ else }}OFF{{ end }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Safety</th>
|
|
<th style="font-size: 0.7em;">Pirani<br />Failsafe</th>
|
|
<td id="failsafe">{{ if .Safety.Failsafe }}ON{{ else }}OFF{{ end }}</td>
|
|
<th style="font-size: 0.7em;">DP<br />Lockout</th>
|
|
<td id="highpressure">{{ if .Safety.HighPressure }}ON{{ else }}OFF{{ end }}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<table>
|
|
<tr>
|
|
<th rowspan="2">Pirani Gauge</th>
|
|
<th>Voltage</th>
|
|
<td id="volts">{{ .Pirani.Volts }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Pressure</th>
|
|
<td id="mbar">{{ .Pirani.Mbar }}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
|
|
<table>
|
|
<tr>
|
|
<th rowspan="3">Control</th>
|
|
<th>RP</th>
|
|
<td colspan="3">
|
|
<button id="rpon">On</button>
|
|
<button id="rpoff">Off</button>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th>DP</th>
|
|
<td colspan="3">
|
|
<button id="dpon">On</button>
|
|
<button id="dpoff">Off</button>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td colspan="3">
|
|
<button id="pd">Pump Down</button>
|
|
<button id="vent">Vent</button>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Status</th>
|
|
<td id="status" colspan="1">OK</td>
|
|
<th>Load</th>
|
|
<td id="load" colspan="1" style="width: 4em;">...</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<div class="graph-container">
|
|
<canvas id="graph" width="1024" height="512" style="max-width: 100%;"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<p style="font-style: italic; font-size: 12px; margin-top: 5em;">
|
|
{{ .System.Hostname }} | load: {{ .System.Load }} | <a href="/debug/pprof">pprof</a> | <a href="/metrics">metrics</a> | ws ping: <span id="ping">…</span>
|
|
</p>
|
|
|
|
<script>
|
|
|
|
let historical = [];
|
|
let canvas = null;
|
|
|
|
// Push a datapoint (in mbar) to the historical buffer, maintaining enough data
|
|
// for ~10 minutes.
|
|
let historicalPush = (v) => {
|
|
historical.push({v: v, time: Date.now() / 1000});
|
|
let len = historical.length;
|
|
// TODO(q3k): trim based on recorded timestamp, not constant buffer size.
|
|
let trim = len - 8192;
|
|
if (trim > 0) {
|
|
historical = historical.slice(trim);
|
|
}
|
|
};
|
|
|
|
// Draw the historical graph and schedule next draw in 100msec.
|
|
let historicalDraw = (w, h) => {
|
|
const now = Date.now() / 1000;
|
|
|
|
// TODO(q3k): better use canvas API to not have so much silly math around
|
|
// coordinate calculation.
|
|
|
|
canvas.clearRect(0, 0, w, h);
|
|
canvas.fillStyle = "#e8e8e8";
|
|
canvas.fillRect(0, 0, w, h);
|
|
|
|
// Margins of the main graph window.
|
|
const marginLeft = 64;
|
|
const marginRight = 32;
|
|
const marginTop = 32;
|
|
const marginBottom = 32;
|
|
|
|
// Draw main graph window.
|
|
canvas.fillStyle = "#f8f8f8";
|
|
canvas.strokeStyle = "#444";
|
|
canvas.lineWidth = 1;
|
|
canvas.fillRect(marginLeft, marginTop, w-(marginLeft+marginRight), h-(marginTop+marginBottom));
|
|
canvas.strokeRect(marginLeft, marginTop, w-(marginLeft+marginRight), h-(marginTop+marginBottom));
|
|
|
|
// Range of decades for Y value.
|
|
const ymin = -4;
|
|
const ymax = 4;
|
|
// Pixels per decade.
|
|
const yscale = (h - (marginTop+marginBottom)) / (ymax - ymin);
|
|
|
|
// For every decade...
|
|
for (let i = ymin; i < ymax; i++) {
|
|
const yoff = (i - ymin) * yscale + yscale / 2;
|
|
const y = Math.floor(h - marginBottom - yoff) + 0.5;
|
|
// Draw Y scale ticks.
|
|
canvas.beginPath();
|
|
canvas.moveTo(marginLeft-5, y);
|
|
canvas.lineTo(marginLeft, y);
|
|
canvas.strokeStyle = "#000";
|
|
canvas.stroke();
|
|
|
|
// Draw Y grid.
|
|
canvas.beginPath();
|
|
canvas.moveTo(marginLeft, y);
|
|
canvas.lineTo(w-marginRight-1, y);
|
|
canvas.strokeStyle = "#ccc";
|
|
canvas.stroke();
|
|
|
|
// Draw Y fine grid.
|
|
if (i > ymin) {
|
|
for (let j = 2; j < 10; j++) {
|
|
let yy = y - Math.log10(j/10) * yscale;
|
|
canvas.beginPath();
|
|
canvas.moveTo(marginLeft, yy);
|
|
canvas.lineTo(w-marginRight-1, yy);
|
|
canvas.strokeStyle = "#eee";
|
|
canvas.stroke();
|
|
}
|
|
}
|
|
|
|
// Draw Y labels.
|
|
canvas.font = "10px sans-serif";
|
|
canvas.fillStyle = "#000";
|
|
canvas.textAlign = "right";
|
|
const text = `10^${i}`;
|
|
canvas.fillText(text, marginLeft-10, y+5);
|
|
}
|
|
|
|
// How much space to leave in front of the graph.
|
|
const xhead = 10;
|
|
|
|
// Draw X labels..
|
|
canvas.textAlign = "center";
|
|
canvas.fillText("Now", w - marginRight - xhead, h - marginBottom + 15)
|
|
canvas.fillText("-10min", marginLeft, h - marginBottom + 15)
|
|
|
|
const xmax = 60 * 10;
|
|
const xscale = (w - (marginLeft+marginRight+xhead)) / (xmax);
|
|
|
|
// Clip to main window.
|
|
canvas.save();
|
|
canvas.beginPath();
|
|
canvas.rect(marginLeft, marginTop, w-(marginLeft+marginRight), h-(marginTop+marginBottom+1));
|
|
canvas.clip();
|
|
|
|
// Draw actual data line.
|
|
let first = true;
|
|
canvas.beginPath();
|
|
historical.forEach((v) => {
|
|
const time = v.time;
|
|
const mbar = v.v;
|
|
const elapsed = now-time;
|
|
if (elapsed > xmax) {
|
|
return;
|
|
}
|
|
|
|
const x = (w - marginRight - xhead) - (elapsed * xscale);
|
|
const yoff = (Math.log10(mbar) - ymin) * yscale + yscale / 2;
|
|
const y = h - marginBottom - yoff;
|
|
|
|
if (first) {
|
|
first = false;
|
|
canvas.moveTo(x, y);
|
|
} else {
|
|
canvas.lineTo(x, y);
|
|
}
|
|
});
|
|
canvas.strokeStyle = "#de1010";
|
|
canvas.lineWidth = 1;
|
|
canvas.stroke();
|
|
canvas.restore();
|
|
|
|
setTimeout(() => { historicalDraw(w, h); }, 100);
|
|
};
|
|
|
|
window.addEventListener("load", (_) => {
|
|
console.log("s u c c");
|
|
|
|
let status = document.querySelector("#status");
|
|
let failsafe = document.querySelector("#failsafe");
|
|
let highpressure = document.querySelector("#highpressure");
|
|
let volts = document.querySelector("#volts");
|
|
let mbar = document.querySelector("#mbar");
|
|
let ping = document.querySelector("#ping");
|
|
let trough = document.querySelector("#trough");
|
|
let thigh = document.querySelector("#thigh");
|
|
let load = document.querySelector("#load");
|
|
|
|
// Buttons
|
|
let pd = document.querySelector("#pd");
|
|
let vent = document.querySelector("#vent");
|
|
let rpon = document.querySelector("#rpon");
|
|
let rpoff = document.querySelector("#rpoff");
|
|
canvas = document.querySelector("#graph").getContext("2d");
|
|
|
|
let colors = {
|
|
default: "background-color: #e8e8e8",
|
|
highlightNeutral: "background-color: #8282ff",
|
|
highlightCaution: "background-color: #ff941a",
|
|
highlightFault: "background-color: #f06060",
|
|
highlightGood: "background-color: #60f060",
|
|
};
|
|
|
|
// TODO(q3k): unhardcode this.
|
|
historicalDraw(1024, 512);
|
|
|
|
// Basic retry loop for connecting to WS.
|
|
let loc = window.location;
|
|
let wsloc = "";
|
|
if (loc.protocol == "https:") {
|
|
wsloc = "wss:";
|
|
} else {
|
|
wsloc = "ws:";
|
|
}
|
|
wsloc += "//" + loc.host + "/stream";
|
|
console.log("Connecting to " + wsloc + "...");
|
|
|
|
let connected = false;
|
|
let connect = () => {
|
|
const socket = new WebSocket(wsloc);
|
|
socket.addEventListener("open", (event) => {
|
|
connected = true;
|
|
console.log("Socket connected!");
|
|
status.innerHTML = "Online";
|
|
status.style = colors.default;
|
|
});
|
|
socket.addEventListener("message", (event) => {
|
|
const data = JSON.parse(event.data);
|
|
volts.innerHTML = data.Pirani.Volts;
|
|
mbar.innerHTML = data.Pirani.Mbar;
|
|
if (data.Safety.Failsafe) {
|
|
failsafe.innerHTML = "ON";
|
|
failsafe.style = colors.highlightFault;
|
|
} else {
|
|
failsafe.innerHTML = "OFF";
|
|
failsafe.style = colors.default;
|
|
}
|
|
if (data.Safety.HighPressure) {
|
|
highpressure.innerHTML = "ON";
|
|
highpressure.style = colors.default;
|
|
} else {
|
|
highpressure.innerHTML = "OFF";
|
|
highpressure.style = colors.default;
|
|
}
|
|
if (data.Pumps.RPOn) {
|
|
rp.innerHTML = "ON";
|
|
rp.style = colors.default;
|
|
} else {
|
|
rp.innerHTML = "OFF";
|
|
rp.style = colors.highlightNeutral;
|
|
}
|
|
if (data.Pumps.DPOn) {
|
|
dp.innerHTML = "ON";
|
|
dp.style = colors.highlightCaution;
|
|
} else {
|
|
dp.innerHTML = "OFF";
|
|
dp.style = colors.default;
|
|
}
|
|
|
|
let t = [];
|
|
if (data.Feedback.RoughReached) {
|
|
trough.innerHTML = "OK";
|
|
trough.style = colors.highlightGood;
|
|
} else {
|
|
trough.innerHTML = "NOK";
|
|
trough.style = colors.default;
|
|
}
|
|
if (data.Feedback.HighReached) {
|
|
thigh.innerHTML = "OK";
|
|
thigh.style = colors.highlightGood;
|
|
} else {
|
|
thigh.innerHTML = "NOK";
|
|
thigh.style = colors.default;
|
|
}
|
|
load.innerHTML = data.LoopLoad.toString() + "%";
|
|
historicalPush(data.Pirani.MbarFloat);
|
|
ping.innerHTML = Date.now();
|
|
});
|
|
socket.addEventListener("close", (event) => {
|
|
status.innerHTML = "Offline";
|
|
status.style = colors.highlightFault;
|
|
if (connected) {
|
|
console.log("Socket dead, reconnecting...");
|
|
}
|
|
connected = false;
|
|
setTimeout(connect, 1000);
|
|
});
|
|
socket.addEventListener("error", (event) => {
|
|
socket.close();
|
|
});
|
|
};
|
|
connect();
|
|
|
|
pd.addEventListener("click", async (event) => {
|
|
await fetch("/button/pumpdown");
|
|
});
|
|
vent.addEventListener("click", async (event) => {
|
|
await fetch("/button/vent");
|
|
});
|
|
rpon.addEventListener("click", async (event) => {
|
|
await fetch("/rp/on");
|
|
});
|
|
rpoff.addEventListener("click", async (event) => {
|
|
await fetch("/rp/off");
|
|
});
|
|
dpon.addEventListener("click", async (event) => {
|
|
await fetch("/dp/on");
|
|
});
|
|
dpoff.addEventListener("click", async (event) => {
|
|
await fetch("/dp/off");
|
|
});
|
|
});
|
|
</script>
|