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.
115 lines
4.4 KiB
TypeScript
115 lines
4.4 KiB
TypeScript
import type {
|
|
PlantAsset, AssetStatus, AssetPhoto, AlertRule, PlantAlert, AppSetting, PhotoType, TelemetryField,
|
|
} 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) })
|
|
}
|
|
export function fetchAssetLatest(id: number): Promise<TelemetryField[]> {
|
|
return request(`/assets/${id}/latest`)
|
|
}
|
|
|
|
// 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')
|
|
}
|