feat(dashboard-frontend): render recommendations in Nodes & Health

Meshtastic → Nodes & Health → Health tab used to be a duplicate
mesh_intelligence config editor (the same MeshIntelligenceSection also
edited from Settings → Intelligence, a stale-tab-overwrite hazard). Replace
it with a real operational view: fetch /api/health and render its
recommendations list.

Three states, matching visual conventions from Dashboard.tsx's alerts list
and MeshCoreRouting.tsx's cross-link note:
- recommendations present → Lightbulb list, one card per item
- empty + recommendations_available → "No recommendations — mesh is
  healthy" (CheckCircle)
- recommendations_available === false → amber warning, not the healthy
  state (AlertTriangle) — a crashed backend must not look healthy

mesh_intelligence config now has exactly one home: Settings. The Health tab
points there via the existing /config?section= deep-link pattern instead of
duplicating the editor. Config.tsx itself is untouched.

MeshHealth.recommendations/.recommendations_available are optional in the
type: the websocket health_update push doesn't include them (REST-only).
This commit is contained in:
Matt Johnson 2026-07-16 22:57:47 +00:00
commit ead5deed0b
2 changed files with 71 additions and 96 deletions

View file

@ -32,7 +32,13 @@ export interface MeshHealth {
total_regions: number
unlocated_count: number
last_computed: string
recommendations: string[]
// Only populated by the REST /api/health response; the websocket
// health_update push includes neither of these (see mesh_routes.py).
recommendations?: string[]
// false when the recommendations engine couldn't run (unwired reporter or
// an exception) — distinct from a genuinely empty `recommendations` list.
// Must NOT be treated the same as "mesh is healthy".
recommendations_available?: boolean
}
export interface NodeInfo {

View file

@ -1,11 +1,9 @@
import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { Link } from 'react-router-dom'
import { RefreshCw, Lightbulb, CheckCircle, AlertTriangle, ExternalLink } from 'lucide-react'
import Mesh from './Mesh'
import MeshtasticSources from './MeshtasticSources'
import { MeshIntelligenceSection, type MeshIntelligenceConfig } from './Config'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchHealth, type MeshHealth } from '@/lib/api'
const TABS = [
{ key: 'nodes', label: 'Nodes' },
@ -19,14 +17,9 @@ export default function MeshtasticNodes() {
const [activeTab, setActiveTab] = useState<TabKey>('nodes')
// Health tab state
const { setDirty } = useDirty()
const [intelligence, setIntelligence] = useState<MeshIntelligenceConfig | null>(null)
const [originalIntelligence, setOriginalIntelligence] = useState<MeshIntelligenceConfig | null>(null)
const [health, setHealth] = useState<MeshHealth | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
useEffect(() => {
document.title = 'Nodes & Health - MeshAI'
@ -36,12 +29,10 @@ export default function MeshtasticNodes() {
setLoading(true)
setError(null)
try {
const data = (await apiFetchConfig('mesh_intelligence')) as MeshIntelligenceConfig
setIntelligence(data)
setOriginalIntelligence(JSON.parse(JSON.stringify(data)))
setHasChanges(false)
const data = await fetchHealth()
setHealth(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load mesh intelligence config')
setError(err instanceof Error ? err.message : 'Failed to load mesh health')
} finally {
setLoading(false)
}
@ -49,46 +40,10 @@ export default function MeshtasticNodes() {
// Load when Health tab becomes active
useEffect(() => {
if (activeTab === 'health' && intelligence === null && !loading) {
if (activeTab === 'health' && health === null && !loading) {
fetchData()
}
}, [activeTab, intelligence, loading, fetchData])
useEffect(() => {
if (intelligence && originalIntelligence) {
setHasChanges(JSON.stringify(intelligence) !== JSON.stringify(originalIntelligence))
}
}, [intelligence, originalIntelligence])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveConfig = async () => {
if (!intelligence) return
setSaving(true)
setError(null)
setSuccess(null)
try {
const result = await apiUpdateConfig('mesh_intelligence', intelligence)
setOriginalIntelligence(JSON.parse(JSON.stringify(intelligence)))
setHasChanges(false)
setDirty(false)
setSuccess('Mesh intelligence saved successfully')
if (result.restart_required) notifyRestartRequired([])
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const discardChanges = () => {
if (originalIntelligence) setIntelligence(JSON.parse(JSON.stringify(originalIntelligence)))
setHasChanges(false)
}
}, [activeTab, health, loading, fetchData])
return (
<div className="space-y-4">
@ -113,64 +68,78 @@ export default function MeshtasticNodes() {
{activeTab === 'nodes' && <Mesh />}
{activeTab === 'sources' && <MeshtasticSources />}
{activeTab === 'health' && (
<div className="max-w-2xl mx-auto space-y-6">
{/* Save bar */}
<div className="max-w-2xl mx-auto space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Mesh health scoring, region management, and automated alerting.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchData}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
<p className="text-sm text-slate-500">
Optimization recommendations generated from current mesh health.
</p>
<button
onClick={fetchData}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />{success}
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="text-slate-400">Loading...</div>
</div>
) : intelligence ? (
<div className="bg-bg-card border border-border p-6">
<MeshIntelligenceSection data={intelligence} onChange={setIntelligence} />
) : health ? (
<div className="bg-bg-card border border-border p-4">
<h2 className="text-[10px] font-sans uppercase tracking-widest text-[#666] mb-3">
Optimization Recommendations
</h2>
{health.recommendations && health.recommendations.length > 0 ? (
<div className="space-y-2">
{health.recommendations.map((rec, i) => (
<div
key={i}
className="p-3 bg-accent/5 border-l-2 border-accent flex items-start gap-3"
>
<Lightbulb size={16} className="text-accent flex-shrink-0 mt-0.5" />
<span className="text-sm font-sans text-[#e0e0e0]">{rec}</span>
</div>
))}
</div>
) : health.recommendations_available === false ? (
<div className="flex items-center gap-2 text-amber-400 py-4">
<AlertTriangle size={16} />
<span className="font-sans">
Couldn't compute recommendations right now. Check server logs.
</span>
</div>
) : (
<div className="flex items-center gap-2 text-[#777] py-4">
<CheckCircle size={16} className="text-green-500" />
<span className="font-sans">No recommendations mesh is healthy.</span>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center h-32">
<div className="text-red-400">Failed to load config</div>
<div className="text-red-400">Failed to load mesh health</div>
</div>
)}
{/* Cross-link note */}
<div className="flex items-start gap-2 p-3 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-400">
<ExternalLink size={16} className="text-accent mt-0.5 flex-shrink-0" />
<div>
Health scoring, region management, and alerting thresholds are configured on{' '}
<Link to="/config?section=mesh_intelligence" className="text-accent hover:underline">
Settings &rarr; Intelligence
</Link>
.
</div>
</div>
</div>
)}
</div>