mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(gui): no-code editor for generic data sources (+ URL preview)
Whitelist generic_sources as a config section and add a Data Sources page: add/edit/delete sources with the full field-mapping UI (items/id/lat/lon/ geometry/title paths + field_mappings list + summary template) and a server-side URL Preview that shows the endpoint's JSON so operators can map fields without knowing the structure ahead of time. Makes the generic adapter truly no-code — point it at any public REST/GeoJSON feed from the GUI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
30212ceb18
commit
ed7d8c8e90
7 changed files with 755 additions and 0 deletions
|
|
@ -22,6 +22,7 @@ import ScheduledBroadcasts from './pages/ScheduledBroadcasts'
|
||||||
import MeshtasticDangerZones from './pages/MeshtasticDangerZones'
|
import MeshtasticDangerZones from './pages/MeshtasticDangerZones'
|
||||||
import MeshCoreDangerZones from './pages/MeshCoreDangerZones'
|
import MeshCoreDangerZones from './pages/MeshCoreDangerZones'
|
||||||
import Coverage from './pages/Coverage'
|
import Coverage from './pages/Coverage'
|
||||||
|
import GenericSources from './pages/GenericSources'
|
||||||
import { ToastProvider } from './components/ToastProvider'
|
import { ToastProvider } from './components/ToastProvider'
|
||||||
import { DirtyProvider } from './context/DirtyContext'
|
import { DirtyProvider } from './context/DirtyContext'
|
||||||
|
|
||||||
|
|
@ -44,6 +45,7 @@ function App() {
|
||||||
{/* New aggregated pages */}
|
{/* New aggregated pages */}
|
||||||
<Route path="/places" element={<Places />} />
|
<Route path="/places" element={<Places />} />
|
||||||
<Route path="/coverage" element={<Coverage />} />
|
<Route path="/coverage" element={<Coverage />} />
|
||||||
|
<Route path="/data-sources" element={<GenericSources />} />
|
||||||
|
|
||||||
{/* De-navved routes still work */}
|
{/* De-navved routes still work */}
|
||||||
<Route path="/gauge-sites" element={<GaugeSites />} />
|
<Route path="/gauge-sites" element={<GaugeSites />} />
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ const navGroups: NavGroup[] = [
|
||||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ path: '/config', label: 'Settings', icon: Settings },
|
{ path: '/config', label: 'Settings', icon: Settings },
|
||||||
{ path: '/environment', label: 'Data Feeds', icon: Cloud },
|
{ path: '/environment', label: 'Data Feeds', icon: Cloud },
|
||||||
|
{ path: '/data-sources', label: 'Data Sources', icon: Layers },
|
||||||
{ path: '/activity', label: 'Activity Log', icon: Activity },
|
{ path: '/activity', label: 'Activity Log', icon: Activity },
|
||||||
{ path: '/places', label: 'Places', icon: MapPin },
|
{ path: '/places', label: 'Places', icon: MapPin },
|
||||||
{ path: '/coverage', label: 'Coverage', icon: Map },
|
{ path: '/coverage', label: 'Coverage', icon: Map },
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,74 @@ export async function updateConfig(
|
||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Generic (no-code) data sources ------------------------------------
|
||||||
|
|
||||||
|
export interface FieldMapping {
|
||||||
|
source_path: string
|
||||||
|
dest_key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenericSource {
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
url: string
|
||||||
|
items_path: string
|
||||||
|
id_path: string
|
||||||
|
lat_path?: string
|
||||||
|
lon_path?: string
|
||||||
|
geometry_path?: string
|
||||||
|
title_path?: string
|
||||||
|
time_path?: string
|
||||||
|
category: string
|
||||||
|
poll_seconds: number
|
||||||
|
severity: string
|
||||||
|
field_mappings: FieldMapping[]
|
||||||
|
summary_template?: string
|
||||||
|
emoji?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenericSourcePreview {
|
||||||
|
ok: boolean
|
||||||
|
status?: number
|
||||||
|
error?: string
|
||||||
|
sample?: string
|
||||||
|
item_count?: number | null
|
||||||
|
first_item?: string
|
||||||
|
items_path_note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGenericSources(): Promise<GenericSource[]> {
|
||||||
|
return fetchJson<GenericSource[]>('/api/config/generic_sources')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGenericSources(
|
||||||
|
sources: GenericSource[]
|
||||||
|
): Promise<{ saved: boolean; restart_required: boolean; changed_keys?: string[] }> {
|
||||||
|
const response = await fetch('/api/config/generic_sources', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(sources),
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error((result as { detail?: string }).detail || `Save failed (${response.status})`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewGenericSource(
|
||||||
|
url: string,
|
||||||
|
items_path?: string
|
||||||
|
): Promise<GenericSourcePreview> {
|
||||||
|
const response = await fetch('/api/generic-sources/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url, items_path }),
|
||||||
|
})
|
||||||
|
// The endpoint never raises; it returns {ok:false,error} on failure too.
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAlerts(): Promise<Alert[]> {
|
export async function fetchAlerts(): Promise<Alert[]> {
|
||||||
return fetchJson<Alert[]>('/api/alerts/active')
|
return fetchJson<Alert[]>('/api/alerts/active')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
562
work/dashboard-frontend/src/pages/GenericSources.tsx
Normal file
562
work/dashboard-frontend/src/pages/GenericSources.tsx
Normal file
|
|
@ -0,0 +1,562 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Save, RotateCcw, Plus, Trash2, Eye, Loader2 } from 'lucide-react'
|
||||||
|
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||||
|
import { useDirty } from '@/context/DirtyContext'
|
||||||
|
import {
|
||||||
|
fetchGenericSources,
|
||||||
|
saveGenericSources,
|
||||||
|
previewGenericSource,
|
||||||
|
type GenericSource,
|
||||||
|
type FieldMapping,
|
||||||
|
type GenericSourcePreview,
|
||||||
|
} from '@/lib/api'
|
||||||
|
|
||||||
|
const SEVERITIES = ['routine', 'priority', 'immediate'] as const
|
||||||
|
|
||||||
|
function blankSource(n: number): GenericSource {
|
||||||
|
return {
|
||||||
|
name: `source_${n}`,
|
||||||
|
enabled: true,
|
||||||
|
url: '',
|
||||||
|
items_path: 'features',
|
||||||
|
id_path: '',
|
||||||
|
lat_path: '',
|
||||||
|
lon_path: '',
|
||||||
|
geometry_path: 'geometry',
|
||||||
|
title_path: '',
|
||||||
|
time_path: '',
|
||||||
|
category: 'generic_alert',
|
||||||
|
poll_seconds: 300,
|
||||||
|
severity: 'routine',
|
||||||
|
field_mappings: [],
|
||||||
|
summary_template: '',
|
||||||
|
emoji: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small labelled text input used throughout the cards.
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
type = 'text',
|
||||||
|
mono = false,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
type?: string
|
||||||
|
mono?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<label className="text-xs text-[#777] mb-1 block">{label}</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={`w-full bg-[#0d0d0d] border border-border px-2 py-1.5 text-xs ${
|
||||||
|
mono ? 'font-mono' : ''
|
||||||
|
} text-[#e0e0e0] placeholder:text-[#555]`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] pt-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenericSources() {
|
||||||
|
const [sources, setSources] = useState<GenericSource[] | null>(null)
|
||||||
|
const [original, setOriginal] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
const { setDirty } = useDirty()
|
||||||
|
|
||||||
|
// Per-card preview state, keyed by source index.
|
||||||
|
const [previews, setPreviews] = useState<Record<number, GenericSourcePreview>>({})
|
||||||
|
const [previewing, setPreviewing] = useState<Record<number, boolean>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = 'Data Sources — MeshAI'
|
||||||
|
fetchGenericSources()
|
||||||
|
.then((data) => {
|
||||||
|
const list = Array.isArray(data) ? data : []
|
||||||
|
// Normalize so every field the form binds to is defined (avoids
|
||||||
|
// controlled/uncontrolled input churn).
|
||||||
|
const norm = list.map((s, i) => ({ ...blankSource(i + 1), ...s }))
|
||||||
|
setSources(norm)
|
||||||
|
setOriginal(JSON.stringify(norm))
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasChanges = sources !== null && JSON.stringify(sources) !== original
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDirty(hasChanges)
|
||||||
|
return () => setDirty(false)
|
||||||
|
}, [hasChanges, setDirty])
|
||||||
|
|
||||||
|
const update = (idx: number, patch: Partial<GenericSource>) => {
|
||||||
|
setSources((prev) =>
|
||||||
|
prev ? prev.map((s, i) => (i === idx ? { ...s, ...patch } : s)) : prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSource = () => {
|
||||||
|
setSources((prev) => [...(prev ?? []), blankSource((prev?.length ?? 0) + 1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSource = (idx: number) => {
|
||||||
|
setSources((prev) => (prev ? prev.filter((_, i) => i !== idx) : prev))
|
||||||
|
setPreviews((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[idx]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addMapping = (idx: number) => {
|
||||||
|
setSources((prev) =>
|
||||||
|
prev
|
||||||
|
? prev.map((s, i) =>
|
||||||
|
i === idx
|
||||||
|
? { ...s, field_mappings: [...s.field_mappings, { source_path: '', dest_key: '' }] }
|
||||||
|
: s,
|
||||||
|
)
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMapping = (idx: number, mIdx: number, patch: Partial<FieldMapping>) => {
|
||||||
|
setSources((prev) =>
|
||||||
|
prev
|
||||||
|
? prev.map((s, i) =>
|
||||||
|
i === idx
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
field_mappings: s.field_mappings.map((m, j) =>
|
||||||
|
j === mIdx ? { ...m, ...patch } : m,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: s,
|
||||||
|
)
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeMapping = (idx: number, mIdx: number) => {
|
||||||
|
setSources((prev) =>
|
||||||
|
prev
|
||||||
|
? prev.map((s, i) =>
|
||||||
|
i === idx
|
||||||
|
? { ...s, field_mappings: s.field_mappings.filter((_, j) => j !== mIdx) }
|
||||||
|
: s,
|
||||||
|
)
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPreview = async (idx: number) => {
|
||||||
|
if (!sources) return
|
||||||
|
const src = sources[idx]
|
||||||
|
setPreviewing((p) => ({ ...p, [idx]: true }))
|
||||||
|
try {
|
||||||
|
const res = await previewGenericSource(src.url, src.items_path)
|
||||||
|
setPreviews((p) => ({ ...p, [idx]: res }))
|
||||||
|
} catch (e) {
|
||||||
|
setPreviews((p) => ({
|
||||||
|
...p,
|
||||||
|
[idx]: { ok: false, error: e instanceof Error ? e.message : String(e) },
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
setPreviewing((p) => ({ ...p, [idx]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
if (original) {
|
||||||
|
setSources(JSON.parse(original) as GenericSource[])
|
||||||
|
setPreviews({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!sources) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
const result = await saveGenericSources(sources)
|
||||||
|
setOriginal(JSON.stringify(sources))
|
||||||
|
setSuccess('Data sources saved')
|
||||||
|
setTimeout(() => setSuccess(null), 3000)
|
||||||
|
if (result.restart_required) {
|
||||||
|
notifyRestartRequired(Array.isArray(result.changed_keys) ? result.changed_keys : [])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Save failed')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64 text-[#777]">
|
||||||
|
Loading data sources…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!sources) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64 text-red-400">
|
||||||
|
{error || 'No config'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionButtons = (
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={discard}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} /> Discard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save size={14} /> {saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
{/* Page description + action bar */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<p className="text-sm text-[#777]">
|
||||||
|
Point MeshAI at any public REST or GeoJSON feed — no code. Each source polls a URL,
|
||||||
|
maps its JSON fields to an event via dotted paths, and broadcasts through the normal
|
||||||
|
coverage-gated pipeline. Use <span className="text-accent">Preview</span> to fetch a
|
||||||
|
URL and read its structure before filling in the paths.
|
||||||
|
</p>
|
||||||
|
{hasChanges && actionButtons}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-red-400 bg-red-500/10 p-3">{error}</div>}
|
||||||
|
{success && <div className="text-sm text-green-400 bg-green-500/10 p-3">{success}</div>}
|
||||||
|
|
||||||
|
{sources.length === 0 && (
|
||||||
|
<div className="border border-border p-6 text-center text-sm text-[#666]">
|
||||||
|
No data sources yet. Click “Add source” to wire up a public feed.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sources.map((src, i) => {
|
||||||
|
const preview = previews[i]
|
||||||
|
const isPreviewing = previewing[i]
|
||||||
|
return (
|
||||||
|
<div key={i} className="border border-border p-4 space-y-4">
|
||||||
|
{/* Header: name + enabled + delete */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={src.name}
|
||||||
|
onChange={(e) => update(i, { name: e.target.value })}
|
||||||
|
placeholder="source name (unique id)"
|
||||||
|
className="flex-1 bg-[#0d0d0d] border border-border px-2 py-1.5 text-sm font-medium text-[#e0e0e0]"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<span className="text-xs text-[#666]">Enabled</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => update(i, { enabled: !src.enabled })}
|
||||||
|
className={`relative w-9 h-4 rounded-full transition-colors ${
|
||||||
|
src.enabled ? 'bg-accent' : 'bg-[#333]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform ${
|
||||||
|
src.enabled ? 'translate-x-5' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteSource(i)}
|
||||||
|
title="Delete source"
|
||||||
|
className="flex items-center gap-1 px-2 py-1.5 text-xs text-[#777] hover:text-red-400 border border-border"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basics */}
|
||||||
|
<SectionLabel>Basics</SectionLabel>
|
||||||
|
<div className="grid grid-cols-12 gap-2">
|
||||||
|
<div className="col-span-12">
|
||||||
|
<Field
|
||||||
|
label="URL"
|
||||||
|
value={src.url}
|
||||||
|
onChange={(v) => update(i, { url: v })}
|
||||||
|
placeholder="https://example.com/api/feed.geojson"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Field
|
||||||
|
label="Poll seconds"
|
||||||
|
type="number"
|
||||||
|
value={src.poll_seconds}
|
||||||
|
onChange={(v) => update(i, { poll_seconds: parseInt(v, 10) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Field
|
||||||
|
label="Category"
|
||||||
|
value={src.category}
|
||||||
|
onChange={(v) => update(i, { category: v })}
|
||||||
|
placeholder="generic_alert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<label className="text-xs text-[#777] mb-1 block">Severity</label>
|
||||||
|
<select
|
||||||
|
value={src.severity}
|
||||||
|
onChange={(e) => update(i, { severity: e.target.value })}
|
||||||
|
className="w-full bg-[#0d0d0d] border border-border px-2 py-1.5 text-xs text-[#e0e0e0]"
|
||||||
|
>
|
||||||
|
{SEVERITIES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Field
|
||||||
|
label="Emoji"
|
||||||
|
value={src.emoji ?? ''}
|
||||||
|
onChange={(v) => update(i, { emoji: v })}
|
||||||
|
placeholder="⚡"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extraction */}
|
||||||
|
<SectionLabel>Extraction</SectionLabel>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Field
|
||||||
|
label="items_path (array of items)"
|
||||||
|
value={src.items_path}
|
||||||
|
onChange={(v) => update(i, { items_path: v })}
|
||||||
|
placeholder="features · object.outages"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="id_path (unique id, for dedup)"
|
||||||
|
value={src.id_path}
|
||||||
|
onChange={(v) => update(i, { id_path: v })}
|
||||||
|
placeholder="id · omsOutageId · properties.id"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<SectionLabel>Location — GeoJSON geometry OR lat + lon paths</SectionLabel>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Field
|
||||||
|
label="geometry_path"
|
||||||
|
value={src.geometry_path ?? ''}
|
||||||
|
onChange={(v) => update(i, { geometry_path: v })}
|
||||||
|
placeholder="geometry"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="lat_path"
|
||||||
|
value={src.lat_path ?? ''}
|
||||||
|
onChange={(v) => update(i, { lat_path: v })}
|
||||||
|
placeholder="properties.lat"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="lon_path"
|
||||||
|
value={src.lon_path ?? ''}
|
||||||
|
onChange={(v) => update(i, { lon_path: v })}
|
||||||
|
placeholder="properties.lon"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display */}
|
||||||
|
<SectionLabel>Display</SectionLabel>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Field
|
||||||
|
label="title_path"
|
||||||
|
value={src.title_path ?? ''}
|
||||||
|
onChange={(v) => update(i, { title_path: v })}
|
||||||
|
placeholder="properties.headline"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="time_path"
|
||||||
|
value={src.time_path ?? ''}
|
||||||
|
onChange={(v) => update(i, { time_path: v })}
|
||||||
|
placeholder="properties.updated"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Field
|
||||||
|
label="summary_template — use {dest_key} tokens from your field mappings"
|
||||||
|
value={src.summary_template ?? ''}
|
||||||
|
onChange={(v) => update(i, { summary_template: v })}
|
||||||
|
placeholder="⚡ Power out — {customers} affected, ETA {eta}"
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field mappings */}
|
||||||
|
<div className="border border-border p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666]">
|
||||||
|
Field Mappings
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => addMapping(i)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-accent/20 hover:bg-accent/30 text-accent border border-accent/30"
|
||||||
|
>
|
||||||
|
<Plus size={12} /> Add mapping
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{src.field_mappings.length === 0 ? (
|
||||||
|
<p className="text-xs text-[#555]">
|
||||||
|
No mappings. Each mapping pulls a dotted <code>source_path</code> from an item
|
||||||
|
into a <code>dest_key</code> you can reference in the summary template.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{src.field_mappings.map((m, mIdx) => (
|
||||||
|
<div key={mIdx} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={m.source_path}
|
||||||
|
onChange={(e) => updateMapping(i, mIdx, { source_path: e.target.value })}
|
||||||
|
placeholder="source_path (e.g. properties.customers)"
|
||||||
|
className="flex-1 bg-[#0d0d0d] border border-border px-2 py-1.5 text-xs font-mono text-[#e0e0e0] placeholder:text-[#555]"
|
||||||
|
/>
|
||||||
|
<span className="text-[#555] text-xs">→</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={m.dest_key}
|
||||||
|
onChange={(e) => updateMapping(i, mIdx, { dest_key: e.target.value })}
|
||||||
|
placeholder="dest_key (e.g. customers)"
|
||||||
|
className="flex-1 bg-[#0d0d0d] border border-border px-2 py-1.5 text-xs font-mono text-[#e0e0e0] placeholder:text-[#555]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeMapping(i, mIdx)}
|
||||||
|
title="Remove mapping"
|
||||||
|
className="flex items-center px-2 py-1.5 text-xs text-[#777] hover:text-red-400 border border-border"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="border border-border p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666]">
|
||||||
|
Preview
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => runPreview(i)}
|
||||||
|
disabled={!src.url || isPreviewing}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-accent/20 hover:bg-accent/30 text-accent border border-accent/30 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{isPreviewing ? <Loader2 size={12} className="animate-spin" /> : <Eye size={12} />}
|
||||||
|
{isPreviewing ? 'Fetching…' : 'Preview'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{preview && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{preview.ok ? (
|
||||||
|
<div className="text-xs text-green-400">
|
||||||
|
HTTP {preview.status ?? '200'}
|
||||||
|
{typeof preview.item_count === 'number' && (
|
||||||
|
<span className="text-[#999]">
|
||||||
|
{' '}
|
||||||
|
— items_path resolved {preview.item_count} item
|
||||||
|
{preview.item_count === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-red-400 break-words">{preview.error}</div>
|
||||||
|
)}
|
||||||
|
{preview.items_path_note && (
|
||||||
|
<div className="text-xs text-amber-400 break-words">
|
||||||
|
{preview.items_path_note}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{preview.first_item && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-widest text-[#555] mb-1">
|
||||||
|
First item
|
||||||
|
</div>
|
||||||
|
<pre className="bg-[#0d0d0d] border border-border p-2 text-[11px] font-mono text-[#bbb] overflow-auto max-h-48 whitespace-pre">
|
||||||
|
{preview.first_item}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{preview.sample && (
|
||||||
|
<details open={!preview.first_item}>
|
||||||
|
<summary className="text-[10px] uppercase tracking-widest text-[#555] cursor-pointer">
|
||||||
|
Raw response
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 bg-[#0d0d0d] border border-border p-2 text-[11px] font-mono text-[#bbb] overflow-auto max-h-72 whitespace-pre">
|
||||||
|
{preview.sample}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add + bottom save bar */}
|
||||||
|
<div className="flex items-center justify-between gap-2 pb-2">
|
||||||
|
<button
|
||||||
|
onClick={addSource}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent/20 hover:bg-accent/30 text-accent border border-accent/30"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Add source
|
||||||
|
</button>
|
||||||
|
{hasChanges && actionButtons}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ RESTART_REQUIRED_SECTIONS = {
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"environmental",
|
"environmental",
|
||||||
"coverage",
|
"coverage",
|
||||||
|
"generic_sources",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Valid config section names
|
# Valid config section names
|
||||||
|
|
@ -58,6 +59,7 @@ VALID_SECTIONS = {
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"danger_zones",
|
"danger_zones",
|
||||||
"coverage",
|
"coverage",
|
||||||
|
"generic_sources",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
118
work/meshai/dashboard/api/generic_sources_routes.py
Normal file
118
work/meshai/dashboard/api/generic_sources_routes.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""Generic-source URL preview endpoint.
|
||||||
|
|
||||||
|
Powers the no-code Data Sources editor: the operator pastes a public REST /
|
||||||
|
GeoJSON URL, hits Preview, and the SERVER (not the browser — avoids CORS) fetches
|
||||||
|
it, so they can SEE the JSON structure and figure out the dotted paths
|
||||||
|
(``items_path`` / ``id_path`` / ``lat_path`` / ``field_mappings`` …) without
|
||||||
|
knowing the schema ahead of time.
|
||||||
|
|
||||||
|
The single route never raises: on any error it returns
|
||||||
|
``{"ok": false, "error": ...}`` so the UI can show the problem inline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request as UrlRequest, urlopen
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
# Reuse the adapter's dotted-path walker so Preview resolves items_path exactly
|
||||||
|
# the way the running GenericHttpAdapter will.
|
||||||
|
from meshai.env.generic_http import _dig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["generic-sources"])
|
||||||
|
|
||||||
|
# Cap the pretty-printed JSON we ship back so a huge feed can't bloat the
|
||||||
|
# response / freeze the browser. ~8 KB is plenty to read the structure.
|
||||||
|
_SAMPLE_MAX = 8192
|
||||||
|
_ITEM_MAX = 2048
|
||||||
|
_FETCH_TIMEOUT = 30
|
||||||
|
_USER_AGENT = "MeshAI/1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, limit: int) -> str:
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[:limit] + f"\n… (truncated, {len(text)} chars total)"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_preview(url: str, items_path: str) -> dict:
|
||||||
|
"""Blocking fetch + parse. NEVER raises — always returns a result dict."""
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
return {"ok": False, "error": "A url is required."}
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = UrlRequest(url, headers={"User-Agent": _USER_AGENT})
|
||||||
|
with urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
||||||
|
status = getattr(resp, "status", None) or resp.getcode()
|
||||||
|
raw = resp.read()
|
||||||
|
except HTTPError as e:
|
||||||
|
return {"ok": False, "status": e.code, "error": f"HTTP {e.code}: {e.reason}"}
|
||||||
|
except URLError as e:
|
||||||
|
return {"ok": False, "error": f"Could not reach URL: {e.reason}"}
|
||||||
|
except Exception as e: # timeout, DNS, connection reset, …
|
||||||
|
return {"ok": False, "error": f"Fetch failed: {e}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8", errors="replace")
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "status": status, "error": f"Decode failed: {e}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except Exception as e:
|
||||||
|
# Not JSON — still show a raw sample so the operator sees what came back.
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"status": status,
|
||||||
|
"error": f"Response is not valid JSON: {e}",
|
||||||
|
"sample": _truncate(text, _SAMPLE_MAX),
|
||||||
|
}
|
||||||
|
|
||||||
|
result: dict = {
|
||||||
|
"ok": True,
|
||||||
|
"status": status,
|
||||||
|
"sample": _truncate(json.dumps(data, indent=2, ensure_ascii=False), _SAMPLE_MAX),
|
||||||
|
}
|
||||||
|
|
||||||
|
# If the operator gave an items_path, resolve it so they can confirm the
|
||||||
|
# array + see a single item's shape (the fields they'll map).
|
||||||
|
if items_path:
|
||||||
|
items = _dig(data, items_path)
|
||||||
|
if isinstance(items, list):
|
||||||
|
result["item_count"] = len(items)
|
||||||
|
if items:
|
||||||
|
result["first_item"] = _truncate(
|
||||||
|
json.dumps(items[0], indent=2, ensure_ascii=False), _ITEM_MAX
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result["item_count"] = None
|
||||||
|
result["items_path_note"] = (
|
||||||
|
f"items_path '{items_path}' did not resolve to a list "
|
||||||
|
f"(got {type(items).__name__})."
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generic-sources/preview")
|
||||||
|
async def preview_generic_source(request: Request):
|
||||||
|
"""Server-side fetch of a candidate feed URL for the no-code editor.
|
||||||
|
|
||||||
|
Body: ``{"url": str, "items_path"?: str}``.
|
||||||
|
Returns ``{ok, status?, error?, sample?, item_count?, first_item?}`` — never
|
||||||
|
raises; failures come back as ``{ok: false, error: ...}``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
|
||||||
|
url = (body or {}).get("url")
|
||||||
|
items_path = (body or {}).get("items_path") or ""
|
||||||
|
|
||||||
|
return await run_in_threadpool(_fetch_preview, url, items_path)
|
||||||
|
|
@ -52,6 +52,7 @@ def create_app() -> FastAPI:
|
||||||
# Import and include API routers
|
# Import and include API routers
|
||||||
from .api.system_routes import router as system_router
|
from .api.system_routes import router as system_router
|
||||||
from .api.config_routes import router as config_router
|
from .api.config_routes import router as config_router
|
||||||
|
from .api.generic_sources_routes import router as generic_sources_router
|
||||||
from .api.mesh_routes import router as mesh_router
|
from .api.mesh_routes import router as mesh_router
|
||||||
from .api.mesh_send_routes import router as mesh_send_router
|
from .api.mesh_send_routes import router as mesh_send_router
|
||||||
from .api.env_routes import router as env_router
|
from .api.env_routes import router as env_router
|
||||||
|
|
@ -65,6 +66,7 @@ def create_app() -> FastAPI:
|
||||||
app.include_router(adapter_config_router, prefix="/api")
|
app.include_router(adapter_config_router, prefix="/api")
|
||||||
app.include_router(curation_router, prefix="/api")
|
app.include_router(curation_router, prefix="/api")
|
||||||
app.include_router(config_router, prefix="/api")
|
app.include_router(config_router, prefix="/api")
|
||||||
|
app.include_router(generic_sources_router, prefix="/api")
|
||||||
app.include_router(mesh_router, prefix="/api")
|
app.include_router(mesh_router, prefix="/api")
|
||||||
app.include_router(mesh_send_router, prefix="/api")
|
app.include_router(mesh_send_router, prefix="/api")
|
||||||
app.include_router(env_router, prefix="/api")
|
app.include_router(env_router, prefix="/api")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue