added an ugly af dashboard

This commit is contained in:
Jono Targett 2026-03-15 21:45:39 +10:30
parent bbb12a5a94
commit b3ff6f069c

187
dashboard.html Normal file
View File

@ -0,0 +1,187 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>MQTT Device Dashboard (Nested Flash)</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
<style>
body { font-family: sans-serif; margin: 1rem; }
.device { border: 1px solid #ccc; padding: 0.5rem; margin: 0.5rem; cursor: pointer; }
.selected { background: #eef; }
.panel { display: flex; gap: 1rem; }
.left, .right { flex: 1; }
.tree ul { list-style: none; padding-left: 1rem; margin: 0; }
.tree li { margin: 2px 0; }
.tree span { cursor: pointer; user-select: none; }
.flash {
animation: flash-bg 1s ease;
}
@keyframes flash-bg {
0% { background-color: yellow; }
100% { background-color: transparent; }
}
</style>
</head>
<body>
<div id="app">
<h1>Device Dashboard</h1>
<div class="panel">
<div class="left">
<h2>Devices</h2>
<div v-for="(device, id) in devices"
:key="id"
@click="selected = id"
:class="['device', {selected: selected===id}]">
{{ id }} — {{ device.status }}
</div>
</div>
<div class="right" v-if="selected">
<h2>Properties & Commands: {{ selected }}</h2>
<div>
<h3>Properties</h3>
<div class="tree">
<tree-view :data="devices[selected].properties"
:flash-map="flashMap"></tree-view>
</div>
</div>
<div>
<h3>Commands</h3>
<ul>
<li v-for="(cmd, name) in devices[selected].commands" :key="name">
{{ name }} — schema: {{ JSON.stringify(cmd.schema) }}
<button @click="sendCommand(selected, name)">Send</button>
</li>
</ul>
</div>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
// Recursive tree component
const TreeView = {
name: "TreeView",
props: ["data", "flashMap", "basePath"],
data() { return { openNodes: new Set() }; },
methods: {
toggle(key) {
if (this.openNodes.has(key)) this.openNodes.delete(key);
else this.openNodes.add(key);
},
isObject(val) { return val && typeof val === "object" && !Array.isArray(val); },
getFlashClass(fullPath) {
return this.flashMap[fullPath] ? "flash" : "";
}
},
template: `
<ul>
<li v-for="(val, key) in data" :key="key">
<span v-if="isObject(val)"
@click="toggle(key)"
:class="getFlashClass(basePath ? basePath + '.' + key : key)">
{{ key }} {{ openNodes.has(key) ? '▼' : '▶' }}
</span>
<span v-else :class="getFlashClass(basePath ? basePath + '.' + key : key)">
{{ key }}: {{ val }}
</span>
<tree-view
v-if="isObject(val) && openNodes.has(key)"
:data="val"
:flash-map="flashMap"
:base-path="basePath ? basePath + '.' + key : key">
</tree-view>
</li>
</ul>
`,
components: { TreeView: null },
created() { this.$options.components.TreeView = TreeView; }
};
createApp({
data() {
return {
devices: {},
selected: null,
client: null,
flashMap: {} // maps "full.path" => boolean
}
},
mounted() {
const clientID = "webui-" + Math.random().toString(16).substr(2, 8);
this.client = mqtt.connect("ws://localhost:8083/mqtt", { clientId: clientID });
this.client.on("connect", () => {
console.log("Connected to broker");
this.client.subscribe("asset/+/status");
this.client.subscribe("asset/+/property/#");
this.client.subscribe("asset/+/command/+");
});
this.client.on("message", (topic, payloadBuffer) => {
const payload = payloadBuffer.toString();
const parts = topic.split("/");
if (parts[0] !== "asset") return;
const deviceID = parts[1];
const category = parts[2];
const keyPath = parts.slice(3);
if (!this.devices[deviceID]) {
this.devices[deviceID] = { status: "OFFLINE", properties: {}, commands: {} };
}
const device = this.devices[deviceID];
if (category === "status") {
device.status = payload;
} else if (category === "property") {
let target = device.properties;
for (let i = 0; i < keyPath.length - 1; i++) {
const k = keyPath[i];
if (!target[k] || typeof target[k] !== "object") target[k] = {};
target = target[k];
}
const leafKey = keyPath[keyPath.length - 1];
let value;
try { value = JSON.parse(payload); }
catch(e) { value = payload; }
target[leafKey] = value;
// generate all ancestor paths for flashing
const paths = [];
for (let i = 0; i < keyPath.length; i++) {
paths.push(keyPath.slice(0, i + 1).join("."));
}
paths.forEach(p => {
this.flashMap[p] = true;
setTimeout(() => { this.flashMap[p] = false; }, 1000);
});
} else if (category === "command" && keyPath.length === 1) {
const cmdName = keyPath[0];
try { device.commands[cmdName] = JSON.parse(payload); }
catch(e) { device.commands[cmdName] = { raw: payload }; }
}
});
},
methods: {
sendCommand(deviceID, commandName) {
const topic = `asset/${deviceID}/command/${commandName}`;
const payload = JSON.stringify({ args: {} });
this.client.publish(topic, payload);
console.log("Sent command", topic, payload);
}
},
components: { TreeView }
}).mount("#app");
</script>
</body>
</html>