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:
commit
503b397dff
38 changed files with 5044 additions and 0 deletions
112
frontend/src/api.ts
Normal file
112
frontend/src/api.ts
Normal 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')
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue