Scaffold plant app - MQTT monitoring for boiler-room equipment

Read-only monitoring/alerting for boilers, water softener, calorifiers
and pumps via a generic MQTT-topic-prefix asset model, so new
equipment can be onboarded without new ingestion code. Threshold and
stale-data alert rules with email + in-app notification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 21:16:02 +00:00
commit 503b397dff
38 changed files with 5044 additions and 0 deletions

112
frontend/src/api.ts Normal file
View file

@ -0,0 +1,112 @@
import type {
PlantAsset, AssetStatus, AssetPhoto, AlertRule, PlantAlert, AppSetting, PhotoType,
} from './types'
const BASE = '/plant/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...opts.headers },
...opts,
})
if (res.status === 401) {
;(window.top ?? window).location.href = '/login'
throw new Error('Unauthenticated')
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Request failed: ${res.status}`)
}
return res.json()
}
// Assets
export function fetchAssets(): Promise<PlantAsset[]> {
return request('/assets')
}
export function createAsset(body: Partial<PlantAsset>): Promise<PlantAsset> {
return request('/assets', { method: 'POST', body: JSON.stringify(body) })
}
export function updateAsset(id: number, body: Partial<PlantAsset>): Promise<PlantAsset> {
return request(`/assets/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
// Asset photos — multipart, so no JSON content-type header
export async function uploadAssetPhoto(assetId: number, file: File, photoType: PhotoType): Promise<AssetPhoto> {
const form = new FormData()
form.append('photo_type', photoType)
form.append('file', file)
const res = await fetch(`${BASE}/assets/${assetId}/photos`, { method: 'POST', credentials: 'include', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || `Upload failed: ${res.status}`)
}
return res.json()
}
export function fetchAssetPhotos(assetId: number): Promise<AssetPhoto[]> {
return request(`/assets/${assetId}/photos`)
}
export function deleteAssetPhoto(id: number): Promise<{ ok: boolean }> {
return request(`/photos/${id}`, { method: 'DELETE' })
}
export function photoUrl(filePath: string): string {
return `${BASE}/uploads${filePath}`
}
// Status (dashboard)
export function fetchStatus(): Promise<{ open_alerts: number; mqtt_connected: boolean; assets: AssetStatus[] }> {
return request('/status')
}
// Alerts
export function fetchAlerts(filters: { status?: string; severity?: string } = {}): Promise<PlantAlert[]> {
const params = new URLSearchParams()
if (filters.status) params.set('status', filters.status)
if (filters.severity) params.set('severity', filters.severity)
const qs = params.toString()
return request(`/alerts${qs ? `?${qs}` : ''}`)
}
export function acknowledgeAlert(id: number): Promise<PlantAlert> {
return request(`/alerts/${id}`, { method: 'PATCH', body: JSON.stringify({ action: 'acknowledge' }) })
}
export function resolveAlert(id: number): Promise<PlantAlert> {
return request(`/alerts/${id}`, { method: 'PATCH', body: JSON.stringify({ action: 'resolve' }) })
}
// Alert rules — threshold/asset_id/asset_type are write-side numbers/nulls,
// distinct enough from AlertRule's read-side (string threshold) shape that a
// plain object type is simpler than fighting Partial<AlertRule> here.
export interface AlertRuleInput {
asset_id?: number | null
asset_type?: AlertRule['asset_type']
field_key?: string
condition?: AlertRule['condition']
threshold?: number
severity?: AlertRule['severity']
active?: boolean
}
export function fetchAlertRules(): Promise<AlertRule[]> {
return request('/alert-rules')
}
export function createAlertRule(body: AlertRuleInput): Promise<AlertRule> {
return request('/alert-rules', { method: 'POST', body: JSON.stringify(body) })
}
export function updateAlertRule(id: number, body: AlertRuleInput): Promise<AlertRule> {
return request(`/alert-rules/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
}
export function deleteAlertRule(id: number): Promise<{ ok: boolean }> {
return request(`/alert-rules/${id}`, { method: 'DELETE' })
}
// Settings
export function getSettings(): Promise<{ settings: AppSetting[] }> {
return request('/settings')
}
export function saveSettings(settings: { key: string; value: string }[]): Promise<{ ok: boolean }> {
return request('/settings', { method: 'PUT', body: JSON.stringify({ settings }) })
}
export function fetchMqttStatus(): Promise<{ connected: boolean; note: string }> {
return request('/settings/mqtt-status')
}