Add configurable water-softener diagram to Dashboard

Per-asset diagram_config (JSONB) maps live MQTT field_keys to brine/resin
tank roles, editable on the Assets page with dropdowns populated from that
asset's actual telemetry. Dashboard renders brine + 1-2 resin tanks with
color-coded fill levels, an "in service" tank indicator, and an animated
flow/regen indicator when regeneration is active.
This commit is contained in:
jtricerolph 2026-07-30 09:02:12 +00:00
parent eca07750d2
commit 4f048a9629
9 changed files with 4779 additions and 11 deletions

View file

@ -24,6 +24,14 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS plant_assets_type_idx ON plant_assets (asset_type);
CREATE UNIQUE INDEX IF NOT EXISTS plant_assets_topic_prefix_idx ON plant_assets (mqtt_topic_prefix) WHERE mqtt_topic_prefix IS NOT NULL;
-- Per-asset visual diagram config (currently only rendered for
-- water_softener) which field_key maps to which role in the diagram
-- (tank A/B capacity, brine level, regen active, tank in service), tank
-- count, and each tank's max capacity in litres (not itself published by
-- the device, only configured on it, so it has to be entered here).
-- NULL until someone sets it up on the Assets page.
ALTER TABLE plant_assets ADD COLUMN IF NOT EXISTS diagram_config JSONB;
-- Raw telemetry history, one row per MQTT message. field_key is whatever
-- topic segment(s) follow the asset's mqtt_topic_prefix (see lib/mqtt.js)
-- generic across any asset type, never a fixed column set. Both a numeric

View file

@ -12,7 +12,7 @@ const ASSET_TYPES = ['boiler', 'water_softener', 'calorifier', 'pump']
const WRITABLE_FIELDS = [
'name', 'asset_type', 'location', 'make_model', 'serial_no',
'install_date', 'notes', 'active', 'mqtt_topic_prefix',
'install_date', 'notes', 'active', 'mqtt_topic_prefix', 'diagram_config',
]
export async function assetRoutes(app, opts) {
@ -35,6 +35,16 @@ export async function assetRoutes(app, opts) {
return rows[0]
})
// GET /api/assets/:id/latest — this asset's known field_keys, for populating
// the diagram-config field-mapping dropdowns (Assets page).
app.get('/api/assets/:id/latest', { preHandler: requireCap('view') }, async (req) => {
const { rows } = await pool.query(
'SELECT asset_id, field_key, value_numeric, value_text, updated_at FROM plant_asset_latest WHERE asset_id = $1 ORDER BY field_key',
[req.params.id]
)
return rows
})
// POST /api/assets — create
app.post('/api/assets', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const b = req.body || {}
@ -45,13 +55,13 @@ export async function assetRoutes(app, opts) {
const { rows } = await pool.query(
`INSERT INTO plant_assets
(name, asset_type, location, make_model, serial_no, install_date, notes, active, mqtt_topic_prefix)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
(name, asset_type, location, make_model, serial_no, install_date, notes, active, mqtt_topic_prefix, diagram_config)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *`,
[
b.name.trim(), b.asset_type, b.location || null, b.make_model || null, b.serial_no || null,
b.install_date || null, b.notes || null, b.active !== undefined ? b.active : true,
b.mqtt_topic_prefix || null,
b.mqtt_topic_prefix || null, b.diagram_config || null,
]
)
await refreshAssetMap().catch(err => app.log.warn(`mqtt asset map refresh failed: ${err.message}`))
@ -77,12 +87,13 @@ export async function assetRoutes(app, opts) {
const { rows } = await pool.query(
`UPDATE plant_assets SET
name = $1, asset_type = $2, location = $3, make_model = $4, serial_no = $5,
install_date = $6, notes = $7, active = $8, mqtt_topic_prefix = $9
WHERE id = $10
install_date = $6, notes = $7, active = $8, mqtt_topic_prefix = $9, diagram_config = $10
WHERE id = $11
RETURNING *`,
[
next.name, next.asset_type, next.location, next.make_model, next.serial_no,
next.install_date, next.notes, next.active, next.mqtt_topic_prefix, req.params.id,
next.install_date, next.notes, next.active, next.mqtt_topic_prefix, next.diagram_config,
req.params.id,
]
)
await refreshAssetMap().catch(err => app.log.warn(`mqtt asset map refresh failed: ${err.message}`))