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 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; 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 -- 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) -- 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 -- 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 = [ const WRITABLE_FIELDS = [
'name', 'asset_type', 'location', 'make_model', 'serial_no', '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) { export async function assetRoutes(app, opts) {
@ -35,6 +35,16 @@ export async function assetRoutes(app, opts) {
return rows[0] 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 // POST /api/assets — create
app.post('/api/assets', { preHandler: requireCap('manage_assets') }, async (req, reply) => { app.post('/api/assets', { preHandler: requireCap('manage_assets') }, async (req, reply) => {
const b = req.body || {} const b = req.body || {}
@ -45,13 +55,13 @@ export async function assetRoutes(app, opts) {
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO plant_assets `INSERT INTO plant_assets
(name, asset_type, location, make_model, serial_no, install_date, notes, active, mqtt_topic_prefix) (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) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *`, RETURNING *`,
[ [
b.name.trim(), b.asset_type, b.location || null, b.make_model || null, b.serial_no || null, 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.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}`)) 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( const { rows } = await pool.query(
`UPDATE plant_assets SET `UPDATE plant_assets SET
name = $1, asset_type = $2, location = $3, make_model = $4, serial_no = $5, name = $1, asset_type = $2, location = $3, make_model = $4, serial_no = $5,
install_date = $6, notes = $7, active = $8, mqtt_topic_prefix = $9 install_date = $6, notes = $7, active = $8, mqtt_topic_prefix = $9, diagram_config = $10
WHERE id = $10 WHERE id = $11
RETURNING *`, RETURNING *`,
[ [
next.name, next.asset_type, next.location, next.make_model, next.serial_no, 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}`)) await refreshAssetMap().catch(err => app.log.warn(`mqtt asset map refresh failed: ${err.message}`))

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import type { import type {
PlantAsset, AssetStatus, AssetPhoto, AlertRule, PlantAlert, AppSetting, PhotoType, PlantAsset, AssetStatus, AssetPhoto, AlertRule, PlantAlert, AppSetting, PhotoType, TelemetryField,
} from './types' } from './types'
const BASE = '/plant/api' const BASE = '/plant/api'
@ -31,6 +31,9 @@ export function createAsset(body: Partial<PlantAsset>): Promise<PlantAsset> {
export function updateAsset(id: number, body: Partial<PlantAsset>): Promise<PlantAsset> { export function updateAsset(id: number, body: Partial<PlantAsset>): Promise<PlantAsset> {
return request(`/assets/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) return request(`/assets/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
} }
export function fetchAssetLatest(id: number): Promise<TelemetryField[]> {
return request(`/assets/${id}/latest`)
}
// Asset photos — multipart, so no JSON content-type header // Asset photos — multipart, so no JSON content-type header
export async function uploadAssetPhoto(assetId: number, file: File, photoType: PhotoType): Promise<AssetPhoto> { export async function uploadAssetPhoto(assetId: number, file: File, photoType: PhotoType): Promise<AssetPhoto> {

View file

@ -0,0 +1,132 @@
import { RefreshCw, ChevronsRight } from 'lucide-react'
import type { AssetStatus, TelemetryField } from '../types'
function fieldByKey(latest: TelemetryField[], key: string | null): TelemetryField | undefined {
if (!key) return undefined
return latest.find(f => f.field_key === key)
}
function numericValue(f?: TelemetryField): number | null {
if (!f || f.value_numeric === null || f.value_numeric === undefined) return null
return Number(f.value_numeric)
}
// Handles whatever shape the underlying MQTT payload actually took —
// ESPHome binary sensors publish "ON"/"OFF" by default but can be configured
// for "true"/"false"; numeric 1/0 is covered by numeric_value === 1 too.
function isTruthy(f?: TelemetryField): boolean {
if (!f) return false
if (f.value_numeric !== null && f.value_numeric !== undefined) return Number(f.value_numeric) === 1
const t = (f.value_text || '').trim().toLowerCase()
return t === 'true' || t === 'on' || t === '1' || t === 'yes'
}
function levelClass(pct: number | null): string {
if (pct === null) return 'softener-fill-unknown'
if (pct < 15) return 'softener-fill-critical'
if (pct < 35) return 'softener-fill-warning'
return 'softener-fill-ok'
}
type Variant = 'active' | 'standby' | 'single'
function tankVariant(active: boolean, regenerating: boolean, single: boolean): Variant {
if (regenerating) return 'active'
if (single) return 'single'
return active ? 'active' : 'standby'
}
function Tank({ label, pct, valueLabel, variant, regenerating }: {
label: string
pct: number | null
valueLabel: string
variant: Variant
regenerating: boolean
}) {
const clamped = pct === null ? 0 : Math.max(0, Math.min(100, pct))
return (
<div className={`softener-tank softener-tank-${variant}`}>
{variant === 'active' && (
<div className={`softener-badge ${regenerating ? 'softener-badge-regen' : ''}`}>
{regenerating && <RefreshCw size={11} strokeWidth={2} className="spin" />}
{regenerating ? 'Regenerating' : 'In service'}
</div>
)}
<div className="softener-tank-shell">
<div className={`softener-tank-fill ${levelClass(pct)}`} style={{ height: `${clamped}%` }} />
<div className="softener-tank-pct">{pct === null ? '—' : `${Math.round(pct)}%`}</div>
</div>
<div className="softener-tank-label">{label}</div>
<div className="softener-tank-value">{valueLabel}</div>
</div>
)
}
export default function WaterSoftenerDiagram({ asset }: { asset: AssetStatus }) {
const cfg = asset.diagram_config
if (!cfg || !cfg.enabled) {
return (
<div className="softener-diagram-empty">
Diagram not set up yet configure field mappings on the Assets page.
</div>
)
}
const single = cfg.tank_count === 1
const tankAField = fieldByKey(asset.latest, cfg.tank_a_field)
const tankBField = fieldByKey(asset.latest, cfg.tank_b_field)
const brineField = fieldByKey(asset.latest, cfg.brine_level_field)
const regenField = fieldByKey(asset.latest, cfg.regen_active_field)
const inServiceField = fieldByKey(asset.latest, cfg.tank_in_service_field)
const regenActive = isTruthy(regenField)
const rawTruthy = isTruthy(inServiceField)
const bInService = !single && (cfg.tank_b_when_truthy ? rawTruthy : !rawTruthy)
const aValL = numericValue(tankAField)
const aPct = aValL !== null && cfg.tank_a_max_l ? (aValL / cfg.tank_a_max_l) * 100 : null
const bValL = numericValue(tankBField)
const bPct = bValL !== null && cfg.tank_b_max_l ? (bValL / cfg.tank_b_max_l) * 100 : null
const brinePct = numericValue(brineField)
return (
<div className="softener-diagram">
{regenActive && (
<div className="softener-regen-banner">
<RefreshCw size={13} strokeWidth={2} className="spin" />
Regeneration in progress
</div>
)}
<div className="softener-diagram-row">
<Tank
label="Brine tank"
pct={brinePct}
valueLabel={brinePct === null ? 'No reading' : `${Math.round(brinePct)}% salt/brine`}
variant="single"
regenerating={false}
/>
<div className={`softener-flow ${regenActive ? 'softener-flow-active' : ''}`} aria-hidden="true">
<ChevronsRight size={16} strokeWidth={2} />
<ChevronsRight size={16} strokeWidth={2} />
<ChevronsRight size={16} strokeWidth={2} />
</div>
<Tank
label={single ? 'Resin tank' : 'Tank A'}
pct={aPct}
valueLabel={aValL === null ? 'No reading' : cfg.tank_a_max_l ? `${Math.round(aValL)} / ${cfg.tank_a_max_l} L` : `${Math.round(aValL)} L`}
variant={tankVariant(!bInService, regenActive && !bInService, single)}
regenerating={regenActive && !bInService}
/>
{!single && (
<Tank
label="Tank B"
pct={bPct}
valueLabel={bValL === null ? 'No reading' : cfg.tank_b_max_l ? `${Math.round(bValL)} / ${cfg.tank_b_max_l} L` : `${Math.round(bValL)} L`}
variant={tankVariant(bInService, regenActive && bInService, false)}
regenerating={regenActive && bInService}
/>
)}
</div>
</div>
)
}

View file

@ -209,6 +209,111 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
.asset-field-key { color: var(--text-mid); } .asset-field-key { color: var(--text-mid); }
.asset-field-value { font-weight: 600; color: var(--text-dark); } .asset-field-value { font-weight: 600; color: var(--text-dark); }
/* ── Water softener diagram ────────────────────────────────── */
.softener-diagram-empty {
font-size: 12px;
color: var(--text-mid);
background: var(--body-bg);
border: 1px dashed var(--card-border);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
}
.softener-diagram { margin-bottom: 12px; }
.softener-regen-banner {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 600;
color: var(--gold);
background: rgba(201,168,76,.12);
border-radius: 6px;
padding: 4px 8px;
margin-bottom: 8px;
width: fit-content;
}
.softener-diagram-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.softener-flow {
display: flex;
align-items: center;
color: var(--card-border);
flex-shrink: 0;
}
.softener-flow svg { margin-left: -6px; }
.softener-flow-active svg { color: var(--app-primary); animation: softener-flow-pulse 1.1s ease-in-out infinite; }
.softener-flow-active svg:nth-child(2) { animation-delay: .15s; }
.softener-flow-active svg:nth-child(3) { animation-delay: .3s; }
@keyframes softener-flow-pulse {
0%, 100% { opacity: .25; }
50% { opacity: 1; }
}
.softener-tank {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 76px;
flex-shrink: 0;
}
.softener-tank-standby { opacity: .5; }
.softener-tank-active .softener-tank-shell { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold); }
.softener-badge {
position: absolute;
top: -9px;
z-index: 1;
display: flex;
align-items: center;
gap: 3px;
background: var(--gold);
color: var(--navy);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .02em;
border-radius: 20px;
padding: 2px 7px;
white-space: nowrap;
}
.softener-badge-regen { background: var(--app-primary); color: #fff; }
.softener-tank-shell {
position: relative;
width: 56px;
height: 90px;
border: 2px solid var(--card-border);
border-radius: 10px 10px 6px 6px;
background: var(--body-bg);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.softener-tank-fill {
position: absolute;
bottom: 0; left: 0; right: 0;
transition: height .5s ease;
}
.softener-fill-ok { background: var(--sev-ok); }
.softener-fill-warning { background: var(--sev-warning); }
.softener-fill-critical { background: var(--sev-critical); }
.softener-fill-unknown { background: var(--card-border); height: 0 !important; }
.softener-tank-pct {
position: relative;
z-index: 1;
font-size: 12px;
font-weight: 700;
color: var(--text-dark);
text-shadow: 0 1px 2px rgba(255,255,255,.6);
}
.softener-tank-label { font-size: 11px; font-weight: 600; color: var(--text-dark); margin-top: 6px; }
.softener-tank-value { font-size: 10px; color: var(--text-mid); }
/* ── Badges ────────────────────────────────────────────────── */ /* ── Badges ────────────────────────────────────────────────── */
.badge { .badge {
display: inline-flex; display: inline-flex;

View file

@ -1,7 +1,7 @@
import { Fragment, useEffect, useState } from 'react' import { Fragment, useEffect, useState } from 'react'
import { ChevronDown, ChevronRight, Plus } from 'lucide-react' import { ChevronDown, ChevronRight, Plus } from 'lucide-react'
import { fetchAssets, createAsset, updateAsset, fetchAssetPhotos } from '../api' import { fetchAssets, createAsset, updateAsset, fetchAssetPhotos, fetchAssetLatest } from '../api'
import type { PlantAsset, AssetPhoto } from '../types' import type { PlantAsset, AssetPhoto, TelemetryField, WaterSoftenerDiagramConfig } from '../types'
import { ASSET_TYPES, ASSET_TYPE_LABELS } from '../types' import { ASSET_TYPES, ASSET_TYPE_LABELS } from '../types'
import AssetPhotoUpload from '../components/AssetPhotoUpload' import AssetPhotoUpload from '../components/AssetPhotoUpload'
import { useAuth } from '../components/AuthGate' import { useAuth } from '../components/AuthGate'
@ -12,6 +12,19 @@ const BLANK_FORM = {
serial_no: '', install_date: '', notes: '', mqtt_topic_prefix: '', serial_no: '', install_date: '', notes: '', mqtt_topic_prefix: '',
} }
const DEFAULT_DIAGRAM_CONFIG: WaterSoftenerDiagramConfig = {
enabled: false,
tank_count: 2,
tank_a_field: null,
tank_a_max_l: null,
tank_b_field: null,
tank_b_max_l: null,
tank_in_service_field: null,
tank_b_when_truthy: true,
brine_level_field: null,
regen_active_field: null,
}
export default function Assets() { export default function Assets() {
const { user } = useAuth() const { user } = useAuth()
const canManage = can(user, 'manage_assets') const canManage = can(user, 'manage_assets')
@ -23,6 +36,9 @@ export default function Assets() {
const [expanded, setExpanded] = useState<number | null>(null) const [expanded, setExpanded] = useState<number | null>(null)
const [photosByAsset, setPhotosByAsset] = useState<Record<number, AssetPhoto[]>>({}) const [photosByAsset, setPhotosByAsset] = useState<Record<number, AssetPhoto[]>>({})
const [editForm, setEditForm] = useState<Record<string, string>>({}) const [editForm, setEditForm] = useState<Record<string, string>>({})
const [fieldsByAsset, setFieldsByAsset] = useState<Record<number, TelemetryField[]>>({})
const [diagramForm, setDiagramForm] = useState<WaterSoftenerDiagramConfig>(DEFAULT_DIAGRAM_CONFIG)
const [savingDiagram, setSavingDiagram] = useState(false)
const [showNew, setShowNew] = useState(false) const [showNew, setShowNew] = useState(false)
const [newForm, setNewForm] = useState(BLANK_FORM) const [newForm, setNewForm] = useState(BLANK_FORM)
@ -48,6 +64,28 @@ export default function Assets() {
const photos = await fetchAssetPhotos(a.id).catch(() => []) const photos = await fetchAssetPhotos(a.id).catch(() => [])
setPhotosByAsset(prev => ({ ...prev, [a.id]: photos })) setPhotosByAsset(prev => ({ ...prev, [a.id]: photos }))
} }
if (a.asset_type === 'water_softener') {
setDiagramForm({ ...DEFAULT_DIAGRAM_CONFIG, ...(a.diagram_config || {}) })
if (!fieldsByAsset[a.id]) {
const fields = await fetchAssetLatest(a.id).catch(() => [])
setFieldsByAsset(prev => ({ ...prev, [a.id]: fields }))
}
}
}
async function saveDiagram(id: number) {
setError(''); setMsg('')
setSavingDiagram(true)
try {
await updateAsset(id, { diagram_config: diagramForm })
setMsg('Diagram config saved')
setTimeout(() => setMsg(''), 2000)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setSavingDiagram(false)
}
} }
async function reloadPhotos(id: number) { async function reloadPhotos(id: number) {
@ -257,6 +295,134 @@ export default function Assets() {
</div> </div>
)} )}
{a.asset_type === 'water_softener' && canManage && (
<>
<div className="section-title" style={{ margin: '4px 0 8px' }}>Dashboard diagram</div>
<div className="field-row">
<div className="field field-check" style={{ flex: '0 0 auto' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="checkbox" checked={diagramForm.enabled}
onChange={e => setDiagramForm({ ...diagramForm, enabled: e.target.checked })}
/>
Show diagram on Dashboard
</label>
</div>
<div className="field" style={{ flex: '0 0 140px' }}>
<label>Resin tanks</label>
<select
value={diagramForm.tank_count}
onChange={e => setDiagramForm({ ...diagramForm, tank_count: Number(e.target.value) as 1 | 2 })}
>
<option value={1}>1 tank</option>
<option value={2}>2 tanks (A/B)</option>
</select>
</div>
</div>
<div className="field-row">
<div className="field">
<label>{diagramForm.tank_count === 1 ? 'Tank capacity field' : 'Tank A capacity field'}</label>
<select
value={diagramForm.tank_a_field || ''}
onChange={e => setDiagramForm({ ...diagramForm, tank_a_field: e.target.value || null })}
>
<option value=""> not mapped </option>
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
</select>
</div>
<div className="field">
<label>{diagramForm.tank_count === 1 ? 'Tank max capacity (L)' : 'Tank A max capacity (L)'}</label>
<input
type="number" min={0}
value={diagramForm.tank_a_max_l ?? ''}
onChange={e => setDiagramForm({ ...diagramForm, tank_a_max_l: e.target.value ? Number(e.target.value) : null })}
placeholder="e.g. 2000"
/>
</div>
</div>
{diagramForm.tank_count === 2 && (
<div className="field-row">
<div className="field">
<label>Tank B capacity field</label>
<select
value={diagramForm.tank_b_field || ''}
onChange={e => setDiagramForm({ ...diagramForm, tank_b_field: e.target.value || null })}
>
<option value=""> not mapped </option>
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
</select>
</div>
<div className="field">
<label>Tank B max capacity (L)</label>
<input
type="number" min={0}
value={diagramForm.tank_b_max_l ?? ''}
onChange={e => setDiagramForm({ ...diagramForm, tank_b_max_l: e.target.value ? Number(e.target.value) : null })}
placeholder="e.g. 2000"
/>
</div>
</div>
)}
<div className="field-row">
<div className="field">
<label>Brine tank level field</label>
<select
value={diagramForm.brine_level_field || ''}
onChange={e => setDiagramForm({ ...diagramForm, brine_level_field: e.target.value || null })}
>
<option value=""> not mapped </option>
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
</select>
<div className="field-hint">Expected as a 0100% value.</div>
</div>
<div className="field">
<label>Regeneration active field</label>
<select
value={diagramForm.regen_active_field || ''}
onChange={e => setDiagramForm({ ...diagramForm, regen_active_field: e.target.value || null })}
>
<option value=""> not mapped </option>
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
</select>
</div>
</div>
{diagramForm.tank_count === 2 && (
<div className="field-row">
<div className="field">
<label>Tank in service field</label>
<select
value={diagramForm.tank_in_service_field || ''}
onChange={e => setDiagramForm({ ...diagramForm, tank_in_service_field: e.target.value || null })}
>
<option value=""> not mapped </option>
{(fieldsByAsset[a.id] || []).map(f => <option key={f.field_key} value={f.field_key}>{f.field_key}</option>)}
</select>
</div>
<div className="field field-check" style={{ flex: '0 0 auto', alignSelf: 'flex-end', marginBottom: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="checkbox" checked={diagramForm.tank_b_when_truthy}
onChange={e => setDiagramForm({ ...diagramForm, tank_b_when_truthy: e.target.checked })}
/>
On means Tank B in service
</label>
</div>
</div>
)}
<button
className="btn btn-primary btn-sm" disabled={savingDiagram}
onClick={() => saveDiagram(a.id)} style={{ marginBottom: 16 }}
>
{savingDiagram ? 'Saving…' : 'Save diagram config'}
</button>
</>
)}
<div className="field-row"> <div className="field-row">
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div className="field-hint" style={{ marginBottom: 4 }}>Asset photo</div> <div className="field-hint" style={{ marginBottom: 4 }}>Asset photo</div>

View file

@ -3,6 +3,7 @@ import { AlertTriangle, WifiOff } from 'lucide-react'
import { fetchStatus } from '../api' import { fetchStatus } from '../api'
import type { AssetStatus, AssetType } from '../types' import type { AssetStatus, AssetType } from '../types'
import { ASSET_TYPES, ASSET_TYPE_LABELS } from '../types' import { ASSET_TYPES, ASSET_TYPE_LABELS } from '../types'
import WaterSoftenerDiagram from '../components/WaterSoftenerDiagram'
const POLL_MS = 30000 const POLL_MS = 30000
@ -32,6 +33,7 @@ function AssetCard({ asset }: { asset: AssetStatus }) {
{asset.location || 'No location set'} {asset.location || 'No location set'}
{asset.mqtt_topic_prefix ? '' : ' · not wired to MQTT yet'} {asset.mqtt_topic_prefix ? '' : ' · not wired to MQTT yet'}
</div> </div>
{asset.asset_type === 'water_softener' && <WaterSoftenerDiagram asset={asset} />}
{asset.latest.length === 0 ? ( {asset.latest.length === 0 ? (
<div className="muted" style={{ fontSize: 12.5 }}>No telemetry received yet.</div> <div className="muted" style={{ fontSize: 12.5 }}>No telemetry received yet.</div>
) : ( ) : (

View file

@ -20,6 +20,24 @@ export const CONDITION_LABELS: Record<AlertCondition, string> = {
stale_minutes: 'Stale for (minutes, no update)', stale_minutes: 'Stale for (minutes, no update)',
} }
// Diagram-config field mappings are all "which field_key on this asset holds
// this value" — kept generic (a plain field_key string) rather than a fixed
// enum so the diagram degrades gracefully if the device's topic naming ever
// changes; the Assets page just re-populates the dropdown from whatever
// field_keys plant_asset_latest currently has for that asset.
export interface WaterSoftenerDiagramConfig {
enabled: boolean
tank_count: 1 | 2
tank_a_field: string | null
tank_a_max_l: number | null
tank_b_field: string | null
tank_b_max_l: number | null
tank_in_service_field: string | null
tank_b_when_truthy: boolean // whether a truthy tank_in_service value means "Tank B in service" (flip if it renders backwards)
brine_level_field: string | null
regen_active_field: string | null
}
export interface PlantAsset { export interface PlantAsset {
id: number id: number
name: string name: string
@ -33,6 +51,7 @@ export interface PlantAsset {
mqtt_topic_prefix: string | null mqtt_topic_prefix: string | null
created_at: string created_at: string
photo_count?: number photo_count?: number
diagram_config: WaterSoftenerDiagramConfig | null
} }
export interface TelemetryField { export interface TelemetryField {