mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(dashboard): serve real recommendations in Nodes & Health; give mesh_intelligence one home (#148)
* refactor(mesh_reporter): expose recommendations as list[str]
Add recommendations_list(scope, scope_value) as the canonical source of
recommendation text. build_recommendations() now just joins that list with
the historical "OPTIMIZATION RECOMMENDATIONS:" header/bullet format it
always used.
router.py:1145 injects build_recommendations()'s output into the LLM system
prompt on the live mesh-DM path — verified byte-identical before/after
across mesh/region/node/missing/empty scopes via a synthetic fixture, and
pinned with a literal-string regression test so a future refactor can't
silently change that prompt text.
This unblocks wiring recommendations into the dashboard, which previously
only reached mesh DMs.
* feat(dashboard): serve real recommendations from /api/health
mesh_reporter was never on app.state — add it alongside health_engine etc.
in server.py's existing pattern. mesh_routes.py's health endpoint now
returns mesh_reporter.recommendations_list("mesh") instead of a hardcoded
[] TODO stub.
Add recommendations_available: bool alongside recommendations: string[] so
"the engine ran and found nothing" (healthy mesh) is never indistinguishable
from "the engine couldn't run" (unwired reporter, or an exception — logged
via logger.exception and swallowed so the rest of the health response still
serves). A crashed recommendations engine must not read as "mesh is
healthy" to an operator.
Also delete main.py's phantom `getattr(mh, "recommendations", [])` on the
websocket health_update push: nothing ever set .recommendations on the
mesh_health object (always []), and no frontend consumer reads it — the
REST endpoint above is the supported path.
* 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).
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
This commit is contained in:
parent
ec973b606a
commit
d7913fddaa
8 changed files with 432 additions and 102 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 → Intelligence
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue