mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(dashboard): Phase E — Activity Log (per-mesh broadcast feed); remove subscription backend
Replace Alerts with an Activity Log fed by per-mesh broadcast logging (transport+channel+success on mesh_broadcasts_out, additive migration). Remove the entire subscription backend (commands, DM dispatch, storage, API) and its UI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c7b3a68f57
commit
9e6a3715ed
19 changed files with 312 additions and 1537 deletions
|
|
@ -4,7 +4,7 @@ import Dashboard from './pages/Dashboard'
|
||||||
import Mesh from './pages/Mesh'
|
import Mesh from './pages/Mesh'
|
||||||
import Environment from './pages/Environment'
|
import Environment from './pages/Environment'
|
||||||
import Config from './pages/Config'
|
import Config from './pages/Config'
|
||||||
import Alerts from './pages/Alerts'
|
import ActivityLog from './pages/ActivityLog'
|
||||||
import Notifications from './pages/Notifications'
|
import Notifications from './pages/Notifications'
|
||||||
import Reference from './pages/Reference'
|
import Reference from './pages/Reference'
|
||||||
import AdapterConfig from './pages/AdapterConfig'
|
import AdapterConfig from './pages/AdapterConfig'
|
||||||
|
|
@ -34,7 +34,8 @@ function App() {
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/environment" element={<Environment />} />
|
<Route path="/environment" element={<Environment />} />
|
||||||
<Route path="/config" element={<Config />} />
|
<Route path="/config" element={<Config />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/alerts" element={<ActivityLog />} />
|
||||||
|
<Route path="/activity" element={<ActivityLog />} />
|
||||||
<Route path="/notifications" element={<Notifications />} />
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
<Route path="/reference" element={<Reference />} />
|
<Route path="/reference" element={<Reference />} />
|
||||||
<Route path="/adapter-config" element={<AdapterConfig />} />
|
<Route path="/adapter-config" element={<AdapterConfig />} />
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Radio,
|
Radio,
|
||||||
Cloud,
|
Cloud,
|
||||||
Bell,
|
|
||||||
BellRing,
|
BellRing,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Sliders,
|
Sliders,
|
||||||
|
|
@ -50,7 +49,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: '/alerts', label: 'Alerts', icon: Bell },
|
{ path: '/activity', label: 'Activity Log', icon: Activity },
|
||||||
{ path: '/places', label: 'Places', icon: MapPin },
|
{ path: '/places', label: 'Places', icon: MapPin },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -111,15 +111,18 @@ export interface AlertHistoryResponse {
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Subscription {
|
export interface ActivityEntry {
|
||||||
id: number
|
id: number
|
||||||
user_id: string
|
sent_at: number | string | null // epoch seconds (int) on new rows
|
||||||
sub_type: string
|
recipient: string | null
|
||||||
schedule_time?: string
|
channel: string | number | null
|
||||||
schedule_day?: string
|
text: string | null
|
||||||
scope_type: string
|
source_event_table: string | null
|
||||||
scope_value?: string
|
source_event_pk: string | number | null
|
||||||
enabled: boolean
|
bytes_sent: number | null
|
||||||
|
ack_received: number | null
|
||||||
|
transport: string | null // 'meshtastic' | 'meshcore' | null (legacy)
|
||||||
|
success: number | null // 1 sent, 0 skip/fail, null legacy
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvStatus {
|
export interface EnvStatus {
|
||||||
|
|
@ -294,8 +297,8 @@ export async function fetchAlertHistory(
|
||||||
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
export async function fetchActivity(limit = 100): Promise<ActivityEntry[]> {
|
||||||
return fetchJson<Subscription[]>('/api/subscriptions')
|
return fetchJson<ActivityEntry[]>(`/api/activity?limit=${limit}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||||
|
|
|
||||||
180
work/dashboard-frontend/src/pages/ActivityLog.tsx
Normal file
180
work/dashboard-frontend/src/pages/ActivityLog.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Activity, Clock, CheckCircle, MinusCircle, Radio } from 'lucide-react'
|
||||||
|
import { fetchActivity, type ActivityEntry } from '@/lib/api'
|
||||||
|
|
||||||
|
// --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// sent_at is stored as int(time.time()) epoch SECONDS on new rows. Guard null
|
||||||
|
// and detect seconds-vs-ms so we render correct local time either way.
|
||||||
|
function formatSentAt(sent_at: number | string | null): string {
|
||||||
|
if (sent_at === null || sent_at === undefined || sent_at === '') return '—'
|
||||||
|
let d: Date
|
||||||
|
if (typeof sent_at === 'number') {
|
||||||
|
// epoch seconds -> ms (values below ~1e12 are seconds)
|
||||||
|
d = new Date(sent_at < 1e12 ? sent_at * 1000 : sent_at)
|
||||||
|
} else {
|
||||||
|
const asNum = Number(sent_at)
|
||||||
|
d = Number.isFinite(asNum) && sent_at.trim() !== ''
|
||||||
|
? new Date(asNum < 1e12 ? asNum * 1000 : asNum)
|
||||||
|
: new Date(sent_at)
|
||||||
|
}
|
||||||
|
return isNaN(d.getTime()) ? '—' : d.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mesh badge styling per transport family.
|
||||||
|
function transportBadge(transport: string | null) {
|
||||||
|
switch (transport) {
|
||||||
|
case 'meshtastic':
|
||||||
|
return { label: 'Meshtastic', cls: 'bg-blue-500/15 text-blue-400 border border-blue-500/30' }
|
||||||
|
case 'meshcore':
|
||||||
|
return { label: 'MeshCore', cls: 'bg-green-500/15 text-green-400 border border-green-500/30' }
|
||||||
|
default:
|
||||||
|
return { label: 'Legacy', cls: 'bg-slate-500/15 text-slate-400 border border-slate-600/40' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel label: Meshtastic index (#n) or MeshCore name; '—' when null.
|
||||||
|
function channelLabel(channel: string | number | null): string {
|
||||||
|
if (channel === null || channel === undefined || channel === '') return '—'
|
||||||
|
if (typeof channel === 'number') return `ch ${channel}`
|
||||||
|
return channel.startsWith('#') ? channel : `#${channel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type/family tag derived from source_event_table (e.g. 'fires' -> 'fire').
|
||||||
|
function familyLabel(table: string | null): string {
|
||||||
|
if (!table) return 'broadcast'
|
||||||
|
const t = table.replace(/_/g, ' ').trim()
|
||||||
|
return t.endsWith('s') ? t.slice(0, -1) : t
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- component -------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function ActivityLog() {
|
||||||
|
const [entries, setEntries] = useState<ActivityEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = 'Activity Log — MeshAI'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
const load = () => {
|
||||||
|
fetchActivity()
|
||||||
|
.then((data) => {
|
||||||
|
if (!alive) return
|
||||||
|
setEntries(data)
|
||||||
|
setError(null)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!alive) return
|
||||||
|
setError(err.message)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
const interval = setInterval(load, 5000)
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-slate-400">Loading activity…</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-red-400">Error: {error}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-bg-card border border-border">
|
||||||
|
<div className="p-4 border-b border-border flex items-center gap-2">
|
||||||
|
<Activity size={14} className="text-[#f59e0b]" />
|
||||||
|
<h2 className="text-sm font-medium text-slate-300">
|
||||||
|
Activity Log
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-slate-500 ml-auto">
|
||||||
|
{entries.length} recent broadcast{entries.length === 1 ? '' : 's'} · newest first
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-slate-500 p-8">
|
||||||
|
<Radio size={18} />
|
||||||
|
<span>No outbound broadcasts recorded yet.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{entries.map((e) => {
|
||||||
|
const badge = transportBadge(e.transport)
|
||||||
|
return (
|
||||||
|
<li key={e.id} className="p-4 hover:bg-bg-hover transition-colors">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* status indicator */}
|
||||||
|
<div className="pt-0.5">
|
||||||
|
{e.success === 1 ? (
|
||||||
|
<CheckCircle size={16} className="text-green-500" />
|
||||||
|
) : e.success === 0 ? (
|
||||||
|
<MinusCircle size={16} className="text-amber-500" />
|
||||||
|
) : (
|
||||||
|
<MinusCircle size={16} className="text-slate-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* meta row: mesh badge, channel, family, status */}
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-1">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${badge.cls}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-bg-hover text-slate-400 border border-border">
|
||||||
|
{channelLabel(e.channel)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-[#f59e0b]/10 text-[#f59e0b]">
|
||||||
|
{familyLabel(e.source_event_table)}
|
||||||
|
</span>
|
||||||
|
{e.success === 1 && (
|
||||||
|
<span className="text-xs text-green-500">Sent</span>
|
||||||
|
)}
|
||||||
|
{e.success === 0 && (
|
||||||
|
<span className="text-xs text-amber-500">Skip</span>
|
||||||
|
)}
|
||||||
|
{(e.success === null || e.success === undefined) && (
|
||||||
|
<span className="text-xs text-slate-500">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* message text */}
|
||||||
|
<div className="text-sm text-slate-200 break-words whitespace-pre-wrap">
|
||||||
|
{e.text || <span className="text-slate-500 italic">(no text)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* timestamp */}
|
||||||
|
<div className="flex items-center gap-1 mt-1.5 text-xs text-slate-500 font-mono">
|
||||||
|
<Clock size={12} />
|
||||||
|
{formatSentAt(e.sent_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,563 +0,0 @@
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import {
|
|
||||||
Bell,
|
|
||||||
AlertTriangle,
|
|
||||||
AlertCircle,
|
|
||||||
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
Filter,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Radio,
|
|
||||||
Zap,
|
|
||||||
|
|
||||||
Cloud,
|
|
||||||
Wifi,
|
|
||||||
WifiOff,
|
|
||||||
Battery,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import {
|
|
||||||
fetchAlerts,
|
|
||||||
fetchAlertHistory,
|
|
||||||
fetchSubscriptions,
|
|
||||||
type Alert,
|
|
||||||
type AlertHistoryItem,
|
|
||||||
type Subscription,
|
|
||||||
} from '@/lib/api'
|
|
||||||
|
|
||||||
interface Node {
|
|
||||||
node_num: number
|
|
||||||
node_id_hex: string
|
|
||||||
short_name: string
|
|
||||||
long_name: string
|
|
||||||
}
|
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
|
||||||
|
|
||||||
// Alert type icons mapping
|
|
||||||
const alertTypeIcons: Record<string, typeof Bell> = {
|
|
||||||
infra_offline: WifiOff,
|
|
||||||
infra_recovery: Wifi,
|
|
||||||
battery_warning: Battery,
|
|
||||||
battery_critical: Battery,
|
|
||||||
battery_emergency: Battery,
|
|
||||||
hf_blackout: Zap,
|
|
||||||
uhf_ducting: Radio,
|
|
||||||
weather_warning: Cloud,
|
|
||||||
weather_watch: Cloud,
|
|
||||||
new_router: Radio,
|
|
||||||
packet_flood: AlertTriangle,
|
|
||||||
sustained_high_util: AlertTriangle,
|
|
||||||
region_blackout: AlertCircle,
|
|
||||||
default: Bell,
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAlertIcon(type: string) {
|
|
||||||
return alertTypeIcons[type] || alertTypeIcons.default
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSeverityStyles(severity: string) {
|
|
||||||
switch (severity?.toLowerCase()) {
|
|
||||||
case 'immediate':
|
|
||||||
return {
|
|
||||||
bg: 'bg-red-500/10',
|
|
||||||
border: 'border-red-500',
|
|
||||||
badge: 'bg-red-500/20 text-red-400',
|
|
||||||
iconColor: 'text-red-500',
|
|
||||||
}
|
|
||||||
case 'priority':
|
|
||||||
return {
|
|
||||||
bg: 'bg-amber-500/10',
|
|
||||||
border: 'border-amber-500',
|
|
||||||
badge: 'bg-amber-500/20 text-amber-400',
|
|
||||||
iconColor: 'text-amber-500',
|
|
||||||
}
|
|
||||||
case 'routine':
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
bg: 'bg-[#f59e0b]/10',
|
|
||||||
border: 'border-[#f59e0b]',
|
|
||||||
badge: 'bg-[#f59e0b]/20 text-[#f59e0b]',
|
|
||||||
iconColor: 'text-[#f59e0b]',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimeAgo(timestamp: string | number): string {
|
|
||||||
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffSec = Math.floor(diffMs / 1000)
|
|
||||||
const diffMin = Math.floor(diffSec / 60)
|
|
||||||
const diffHour = Math.floor(diffMin / 60)
|
|
||||||
const diffDay = Math.floor(diffHour / 24)
|
|
||||||
|
|
||||||
if (diffSec < 60) return 'Just now'
|
|
||||||
if (diffMin < 60) return `${diffMin}m ago`
|
|
||||||
if (diffHour < 24) return `${diffHour}h ago`
|
|
||||||
return `${diffDay}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(timestamp: string | number): string {
|
|
||||||
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
|
||||||
if (seconds < 60) return `${seconds}s`
|
|
||||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
|
||||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
|
|
||||||
return `${Math.floor(seconds / 86400)}d`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active Alert Card Component
|
|
||||||
function ActiveAlertCard({
|
|
||||||
alert,
|
|
||||||
onAcknowledge,
|
|
||||||
}: {
|
|
||||||
alert: Alert
|
|
||||||
onAcknowledge: (alert: Alert) => void
|
|
||||||
}) {
|
|
||||||
const styles = getSeverityStyles(alert.severity)
|
|
||||||
const Icon = getAlertIcon(alert.type)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`p-4 ${styles.bg} border-l-4 ${styles.border}`}>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Icon size={20} className={styles.iconColor} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
|
|
||||||
{alert.severity?.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-500">{alert.type}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-slate-200">{alert.message}</div>
|
|
||||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock size={12} />
|
|
||||||
{alert.timestamp ? formatTimeAgo(alert.timestamp) : 'Just now'}
|
|
||||||
</span>
|
|
||||||
{alert.scope_value && (
|
|
||||||
<span>{alert.scope_type}: {alert.scope_value}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onAcknowledge(alert)}
|
|
||||||
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-200 border border-border rounded hover:bg-bg-hover transition-colors"
|
|
||||||
>
|
|
||||||
Acknowledge
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alert History Table Component
|
|
||||||
function AlertHistoryTable({
|
|
||||||
history,
|
|
||||||
typeFilter,
|
|
||||||
severityFilter,
|
|
||||||
onTypeFilterChange,
|
|
||||||
onSeverityFilterChange,
|
|
||||||
page,
|
|
||||||
totalPages,
|
|
||||||
onPageChange,
|
|
||||||
}: {
|
|
||||||
history: AlertHistoryItem[]
|
|
||||||
typeFilter: string
|
|
||||||
severityFilter: string
|
|
||||||
onTypeFilterChange: (v: string) => void
|
|
||||||
onSeverityFilterChange: (v: string) => void
|
|
||||||
page: number
|
|
||||||
totalPages: number
|
|
||||||
onPageChange: (p: number) => void
|
|
||||||
}) {
|
|
||||||
const alertTypes = [
|
|
||||||
'all',
|
|
||||||
'infra_offline',
|
|
||||||
'infra_recovery',
|
|
||||||
'battery_warning',
|
|
||||||
'battery_critical',
|
|
||||||
'hf_blackout',
|
|
||||||
'uhf_ducting',
|
|
||||||
'weather_warning',
|
|
||||||
'new_router',
|
|
||||||
'packet_flood',
|
|
||||||
]
|
|
||||||
|
|
||||||
const severities = ["all", "immediate", "priority", "routine"]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-bg-card border border-border">
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="p-4 border-b border-border flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Filter size={14} className="text-slate-400" />
|
|
||||||
<span className="text-sm text-slate-400">Filter:</span>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={typeFilter}
|
|
||||||
onChange={(e) => onTypeFilterChange(e.target.value)}
|
|
||||||
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-[#f59e0b]"
|
|
||||||
>
|
|
||||||
{alertTypes.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{t === 'all' ? 'All Types' : t.replace(/_/g, ' ')}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={severityFilter}
|
|
||||||
onChange={(e) => onSeverityFilterChange(e.target.value)}
|
|
||||||
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-[#f59e0b]"
|
|
||||||
>
|
|
||||||
{severities.map((s) => (
|
|
||||||
<option key={s} value={s}>
|
|
||||||
{s === 'all' ? 'All Severities' : s.charAt(0).toUpperCase() + s.slice(1)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border">
|
|
||||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Time</th>
|
|
||||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Type</th>
|
|
||||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Severity</th>
|
|
||||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Message</th>
|
|
||||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{history.length > 0 ? (
|
|
||||||
history.map((item, i) => {
|
|
||||||
const styles = getSeverityStyles(item.severity)
|
|
||||||
return (
|
|
||||||
<tr key={item.id || i} className="border-b border-border hover:bg-bg-hover">
|
|
||||||
<td className="p-4 text-sm text-slate-400 font-mono whitespace-nowrap">
|
|
||||||
{formatDateTime(item.timestamp)}
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-sm text-slate-300">
|
|
||||||
{item.type.replace(/_/g, ' ')}
|
|
||||||
</td>
|
|
||||||
<td className="p-4">
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
|
|
||||||
{item.severity}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-sm text-slate-200 max-w-md truncate">
|
|
||||||
{item.message}
|
|
||||||
</td>
|
|
||||||
<td className="p-4 text-sm text-slate-400 font-mono">
|
|
||||||
{item.duration ? formatDuration(item.duration) : '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="p-8 text-center text-slate-500">
|
|
||||||
No alert history available
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="p-4 border-t border-border flex items-center justify-between">
|
|
||||||
<span className="text-sm text-slate-400">
|
|
||||||
Page {page} of {totalPages}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onPageChange(page - 1)}
|
|
||||||
disabled={page <= 1}
|
|
||||||
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onPageChange(page + 1)}
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscription Card Component
|
|
||||||
function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) {
|
|
||||||
const resolveNodeName = (userId: string): string => {
|
|
||||||
const node = nodes.find(n =>
|
|
||||||
n.node_id_hex === userId ||
|
|
||||||
String(n.node_num) === userId ||
|
|
||||||
n.short_name === userId
|
|
||||||
)
|
|
||||||
if (node) {
|
|
||||||
return node.long_name && node.long_name !== node.short_name
|
|
||||||
? `${node.short_name} (${node.long_name})`
|
|
||||||
: node.short_name
|
|
||||||
}
|
|
||||||
return userId
|
|
||||||
}
|
|
||||||
const formatSchedule = () => {
|
|
||||||
if (subscription.sub_type === 'alerts') {
|
|
||||||
return 'Real-time'
|
|
||||||
}
|
|
||||||
const time = subscription.schedule_time || '0000'
|
|
||||||
const hours = parseInt(time.slice(0, 2))
|
|
||||||
const minutes = time.slice(2)
|
|
||||||
const period = hours >= 12 ? 'PM' : 'AM'
|
|
||||||
const displayHour = hours % 12 || 12
|
|
||||||
let schedule = `${displayHour}:${minutes} ${period}`
|
|
||||||
if (subscription.sub_type === 'weekly' && subscription.schedule_day) {
|
|
||||||
schedule += ` ${subscription.schedule_day.charAt(0).toUpperCase()}${subscription.schedule_day.slice(1)}`
|
|
||||||
}
|
|
||||||
return schedule
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTypeIcon = () => {
|
|
||||||
switch (subscription.sub_type) {
|
|
||||||
case 'alerts':
|
|
||||||
return Bell
|
|
||||||
case 'daily':
|
|
||||||
return Clock
|
|
||||||
case 'weekly':
|
|
||||||
return Clock
|
|
||||||
default:
|
|
||||||
return Bell
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Icon = getTypeIcon()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 bg-bg-hover border border-border">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-[#f59e0b]/10 flex items-center justify-center">
|
|
||||||
<Icon size={18} className="text-[#f59e0b]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm text-slate-200 font-medium">
|
|
||||||
{subscription.sub_type.charAt(0).toUpperCase() + subscription.sub_type.slice(1)}
|
|
||||||
{subscription.scope_type !== 'mesh' && subscription.scope_value && (
|
|
||||||
<span className="text-slate-400 font-normal ml-2">
|
|
||||||
({subscription.scope_type}: {subscription.scope_value})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-500 mt-0.5">
|
|
||||||
{formatSchedule()} • {resolveNodeName(subscription.user_id)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`w-2 h-2 rounded-full ${subscription.enabled ? 'bg-green-500' : 'bg-slate-500'}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Alerts() {
|
|
||||||
const [activeAlerts, setActiveAlerts] = useState<Alert[]>([])
|
|
||||||
const [history, setHistory] = useState<AlertHistoryItem[]>([])
|
|
||||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
|
|
||||||
const [nodes, setNodes] = useState<Node[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Filters and pagination
|
|
||||||
const [typeFilter, setTypeFilter] = useState('all')
|
|
||||||
const [severityFilter, setSeverityFilter] = useState('all')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [totalPages, setTotalPages] = useState(1)
|
|
||||||
const pageSize = 20
|
|
||||||
|
|
||||||
// Acknowledged alerts (local state only)
|
|
||||||
const [acknowledged, setAcknowledged] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const { lastAlert } = useWebSocket()
|
|
||||||
|
|
||||||
// Set page title
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = 'Alerts — MeshAI'
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Load data
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([
|
|
||||||
fetchAlerts().catch(() => []),
|
|
||||||
fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })),
|
|
||||||
fetchSubscriptions().catch(() => []),
|
|
||||||
fetch('/api/nodes').then(r => r.json()).catch(() => []),
|
|
||||||
])
|
|
||||||
.then(([alerts, historyData, subs, nodeData]) => {
|
|
||||||
setActiveAlerts(alerts)
|
|
||||||
if (Array.isArray(historyData)) {
|
|
||||||
setHistory(historyData)
|
|
||||||
setTotalPages(1)
|
|
||||||
} else {
|
|
||||||
setHistory(historyData.items || [])
|
|
||||||
setTotalPages(Math.ceil((historyData.total || 0) / pageSize))
|
|
||||||
}
|
|
||||||
setSubscriptions(subs)
|
|
||||||
setNodes(nodeData)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setError(err.message)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Handle new alerts from WebSocket
|
|
||||||
useEffect(() => {
|
|
||||||
if (lastAlert) {
|
|
||||||
setActiveAlerts((prev) => {
|
|
||||||
// Avoid duplicates
|
|
||||||
const exists = prev.some(
|
|
||||||
(a) => a.type === lastAlert.type && a.message === lastAlert.message
|
|
||||||
)
|
|
||||||
if (exists) return prev
|
|
||||||
return [lastAlert, ...prev]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [lastAlert])
|
|
||||||
|
|
||||||
// Reload history when filters or page change
|
|
||||||
useEffect(() => {
|
|
||||||
const offset = (page - 1) * pageSize
|
|
||||||
fetchAlertHistory(pageSize, offset, typeFilter, severityFilter)
|
|
||||||
.then((data) => {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
setHistory(data)
|
|
||||||
setTotalPages(1)
|
|
||||||
} else {
|
|
||||||
setHistory(data.items || [])
|
|
||||||
setTotalPages(Math.ceil((data.total || 0) / pageSize))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Keep current data on error
|
|
||||||
})
|
|
||||||
}, [page, typeFilter, severityFilter])
|
|
||||||
|
|
||||||
const handleAcknowledge = useCallback((alert: Alert) => {
|
|
||||||
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
|
|
||||||
setAcknowledged((prev) => new Set([...prev, key]))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Filter out acknowledged alerts
|
|
||||||
const visibleAlerts = activeAlerts.filter((alert) => {
|
|
||||||
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
|
|
||||||
return !acknowledged.has(key)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-slate-400">Loading alerts...</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-red-400">Error: {error}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Active Alerts */}
|
|
||||||
<div className="bg-bg-card border border-border p-6">
|
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
|
||||||
<AlertTriangle size={14} />
|
|
||||||
Active Alerts ({visibleAlerts.length})
|
|
||||||
</h2>
|
|
||||||
{visibleAlerts.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{visibleAlerts.map((alert, i) => (
|
|
||||||
<ActiveAlertCard
|
|
||||||
key={`${alert.type}-${alert.timestamp}-${i}`}
|
|
||||||
alert={alert}
|
|
||||||
onAcknowledge={handleAcknowledge}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 text-slate-500 py-8">
|
|
||||||
<CheckCircle size={20} className="text-green-500" />
|
|
||||||
<span>No active alerts — all systems nominal</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert History */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
|
||||||
<Clock size={14} />
|
|
||||||
Alert History
|
|
||||||
</h2>
|
|
||||||
<AlertHistoryTable
|
|
||||||
history={history}
|
|
||||||
typeFilter={typeFilter}
|
|
||||||
severityFilter={severityFilter}
|
|
||||||
onTypeFilterChange={(v) => {
|
|
||||||
setTypeFilter(v)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
onSeverityFilterChange={(v) => {
|
|
||||||
setSeverityFilter(v)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
page={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subscriptions */}
|
|
||||||
<div className="bg-bg-card border border-border p-6">
|
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
|
||||||
<Users size={14} />
|
|
||||||
Mesh Subscriptions ({subscriptions.length})
|
|
||||||
</h2>
|
|
||||||
{subscriptions.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{subscriptions.map((sub) => (
|
|
||||||
<SubscriptionCard key={sub.id} subscription={sub} nodes={nodes} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-slate-500 py-4">
|
|
||||||
<p>No active subscriptions.</p>
|
|
||||||
<p className="text-xs mt-2">
|
|
||||||
Manage subscriptions via <code className="text-[#f59e0b]">!subscribe</code> on mesh. Broadcasts arrive with one of three prefixes — <strong>New:</strong> (first sight), <strong>Update:</strong> (material change), or <strong>Active:</strong> (clock-driven reminder while the event is still live). See <a href="/reference#broadcast-types" className="text-[#f59e0b] hover:underline">Broadcast Types</a> and <a href="/reference#reminders" className="text-[#f59e0b] hover:underline">Reminder System</a> in Reference.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -267,9 +267,6 @@ const AVAILABLE_COMMANDS = [
|
||||||
{ name: 'ping', description: 'Test bot responsiveness' },
|
{ name: 'ping', description: 'Test bot responsiveness' },
|
||||||
{ name: 'clear', description: 'Clear your conversation history' },
|
{ name: 'clear', description: 'Clear your conversation history' },
|
||||||
{ name: 'reset', description: 'Reset conversation context' },
|
{ name: 'reset', description: 'Reset conversation context' },
|
||||||
{ name: 'sub', description: 'Subscribe to scheduled reports or alerts' },
|
|
||||||
{ name: 'unsub', description: 'Remove a subscription' },
|
|
||||||
{ name: 'mysubs', description: 'List your active subscriptions' },
|
|
||||||
{ name: 'alerts', description: 'Active NWS weather alerts for mesh area' },
|
{ name: 'alerts', description: 'Active NWS weather alerts for mesh area' },
|
||||||
{ name: 'solar', description: 'Space weather and HF propagation conditions' },
|
{ name: 'solar', description: 'Space weather and HF propagation conditions' },
|
||||||
{ name: 'hf', description: 'HF radio propagation (alias for !solar)' },
|
{ name: 'hf', description: 'HF radio propagation (alias for !solar)' },
|
||||||
|
|
|
||||||
|
|
@ -1186,18 +1186,6 @@ export default function Reference() {
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHeader>Subscription Commands</SectionHeader>
|
|
||||||
<RefTable
|
|
||||||
headers={['Command', 'What It Does']}
|
|
||||||
rows={[
|
|
||||||
[<Mono>!subscribe</Mono>, 'Lists all alert categories you can subscribe to'],
|
|
||||||
[<Mono>!subscribe fire_proximity</Mono>, 'Subscribe to a specific category'],
|
|
||||||
[<Mono>!subscribe all</Mono>, 'Subscribe to everything'],
|
|
||||||
[<Mono>!unsubscribe fire_proximity</Mono>, 'Unsubscribe from a category'],
|
|
||||||
[<Mono>!subscriptions</Mono>, "Shows what you're currently subscribed to"],
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SectionHeader>Conversational</SectionHeader>
|
<SectionHeader>Conversational</SectionHeader>
|
||||||
<p>
|
<p>
|
||||||
Bang commands are the short, predictable interface. For anything that
|
Bang commands are the short, predictable interface. For anything that
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ if TYPE_CHECKING:
|
||||||
from .config import AlertRulesConfig, MeshIntelligenceConfig
|
from .config import AlertRulesConfig, MeshIntelligenceConfig
|
||||||
from .mesh_health import MeshHealthEngine
|
from .mesh_health import MeshHealthEngine
|
||||||
from .mesh_reporter import MeshReporter
|
from .mesh_reporter import MeshReporter
|
||||||
from .subscriptions import SubscriptionManager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -65,14 +64,12 @@ class AlertEngine:
|
||||||
self,
|
self,
|
||||||
health_engine: "MeshHealthEngine",
|
health_engine: "MeshHealthEngine",
|
||||||
reporter: "MeshReporter",
|
reporter: "MeshReporter",
|
||||||
subscription_manager: "SubscriptionManager",
|
|
||||||
config: "MeshIntelligenceConfig",
|
config: "MeshIntelligenceConfig",
|
||||||
db_path: str = "",
|
db_path: str = "",
|
||||||
timezone: str = "America/Boise",
|
timezone: str = "America/Boise",
|
||||||
):
|
):
|
||||||
self._health = health_engine
|
self._health = health_engine
|
||||||
self._reporter = reporter
|
self._reporter = reporter
|
||||||
self._subs = subscription_manager
|
|
||||||
self._rules = config.alert_rules
|
self._rules = config.alert_rules
|
||||||
self._critical_nodes = set(n.upper() for n in (config.critical_nodes or []))
|
self._critical_nodes = set(n.upper() for n in (config.critical_nodes or []))
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
|
|
@ -580,14 +577,6 @@ class AlertEngine:
|
||||||
def clear_pending(self):
|
def clear_pending(self):
|
||||||
self._pending_alerts = []
|
self._pending_alerts = []
|
||||||
|
|
||||||
def get_subscribers_for_alert(self, alert: dict) -> list[dict]:
|
|
||||||
if not self._subs:
|
|
||||||
return []
|
|
||||||
return self._subs.get_alert_subscribers(
|
|
||||||
scope_type=alert.get("scope_type"),
|
|
||||||
scope_value=alert.get("scope_value"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def check_environmental(self, env_store) -> list[dict]:
|
def check_environmental(self, env_store) -> list[dict]:
|
||||||
"""Check environmental feeds for alertable conditions.
|
"""Check environmental feeds for alertable conditions.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,9 +160,7 @@ def create_dispatcher(
|
||||||
mesh_reporter=None,
|
mesh_reporter=None,
|
||||||
data_store=None,
|
data_store=None,
|
||||||
health_engine=None,
|
health_engine=None,
|
||||||
subscription_manager=None,
|
|
||||||
env_store=None,
|
env_store=None,
|
||||||
notification_router=None,
|
|
||||||
) -> CommandDispatcher:
|
) -> CommandDispatcher:
|
||||||
"""Create and populate command dispatcher with default commands.
|
"""Create and populate command dispatcher with default commands.
|
||||||
|
|
||||||
|
|
@ -173,7 +171,6 @@ def create_dispatcher(
|
||||||
mesh_reporter: MeshReporter instance for health commands
|
mesh_reporter: MeshReporter instance for health commands
|
||||||
data_store: MeshDataStore for neighbor data
|
data_store: MeshDataStore for neighbor data
|
||||||
health_engine: MeshHealthEngine for infrastructure detection
|
health_engine: MeshHealthEngine for infrastructure detection
|
||||||
subscription_manager: SubscriptionManager for subscription commands
|
|
||||||
env_store: EnvironmentalStore for weather/propagation commands
|
env_store: EnvironmentalStore for weather/propagation commands
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -186,7 +183,6 @@ def create_dispatcher(
|
||||||
from .status import StatusCommand
|
from .status import StatusCommand
|
||||||
from .weather import WeatherCommand
|
from .weather import WeatherCommand
|
||||||
from .health import HealthCommand, RegionCommand, NeighborCommand
|
from .health import HealthCommand, RegionCommand, NeighborCommand
|
||||||
from .subscribe import SubCommand, UnsubCommand, MySubsCommand
|
|
||||||
|
|
||||||
dispatcher = CommandDispatcher(prefix=prefix, disabled_commands=disabled_commands)
|
dispatcher = CommandDispatcher(prefix=prefix, disabled_commands=disabled_commands)
|
||||||
|
|
||||||
|
|
@ -224,28 +220,6 @@ def create_dispatcher(
|
||||||
alias_handler.name = alias
|
alias_handler.name = alias
|
||||||
dispatcher.register(alias_handler)
|
dispatcher.register(alias_handler)
|
||||||
|
|
||||||
# Register subscription commands
|
|
||||||
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
|
||||||
dispatcher.register(sub_cmd)
|
|
||||||
for alias in getattr(sub_cmd, 'aliases', []):
|
|
||||||
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
|
||||||
alias_handler.name = alias
|
|
||||||
dispatcher.register(alias_handler)
|
|
||||||
|
|
||||||
unsub_cmd = UnsubCommand(subscription_manager, notification_router)
|
|
||||||
dispatcher.register(unsub_cmd)
|
|
||||||
for alias in getattr(unsub_cmd, 'aliases', []):
|
|
||||||
alias_handler = UnsubCommand(subscription_manager, notification_router)
|
|
||||||
alias_handler.name = alias
|
|
||||||
dispatcher.register(alias_handler)
|
|
||||||
|
|
||||||
mysubs_cmd = MySubsCommand(subscription_manager, notification_router)
|
|
||||||
dispatcher.register(mysubs_cmd)
|
|
||||||
for alias in getattr(mysubs_cmd, 'aliases', []):
|
|
||||||
alias_handler = MySubsCommand(subscription_manager, notification_router)
|
|
||||||
alias_handler.name = alias
|
|
||||||
dispatcher.register(alias_handler)
|
|
||||||
|
|
||||||
# Register environmental commands
|
# Register environmental commands
|
||||||
if env_store:
|
if env_store:
|
||||||
from .alerts_cmd import AlertsCommand
|
from .alerts_cmd import AlertsCommand
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,9 @@ class HelpCommand(CommandHandler):
|
||||||
|
|
||||||
# Group by category
|
# Group by category
|
||||||
health_names = {"health", "region", "neighbors"}
|
health_names = {"health", "region", "neighbors"}
|
||||||
sub_names = {"sub", "unsub", "mysubs"}
|
|
||||||
|
|
||||||
health_cmds = [c for c in unique if c.name.lower() in health_names]
|
health_cmds = [c for c in unique if c.name.lower() in health_names]
|
||||||
sub_cmds = [c for c in unique if c.name.lower() in sub_names]
|
other_cmds = [c for c in unique if c.name.lower() not in health_names and c.name.lower() != "help"]
|
||||||
other_cmds = [c for c in unique if c.name.lower() not in health_names and c.name.lower() not in sub_names and c.name.lower() != "help"]
|
|
||||||
|
|
||||||
lines = ["Commands:"]
|
lines = ["Commands:"]
|
||||||
|
|
||||||
|
|
@ -46,12 +44,6 @@ class HelpCommand(CommandHandler):
|
||||||
for c in sorted(health_cmds, key=lambda x: x.name):
|
for c in sorted(health_cmds, key=lambda x: x.name):
|
||||||
lines.append(f" !{c.name} - {c.description}")
|
lines.append(f" !{c.name} - {c.description}")
|
||||||
|
|
||||||
if sub_cmds:
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Subscriptions:")
|
|
||||||
for c in sorted(sub_cmds, key=lambda x: x.name):
|
|
||||||
lines.append(f" !{c.name} - {c.description}")
|
|
||||||
|
|
||||||
if other_cmds:
|
if other_cmds:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Other:")
|
lines.append("Other:")
|
||||||
|
|
@ -67,9 +59,6 @@ class HelpCommand(CommandHandler):
|
||||||
def _command_help(self, cmd_name: str) -> str:
|
def _command_help(self, cmd_name: str) -> str:
|
||||||
"""Detailed help for a specific command."""
|
"""Detailed help for a specific command."""
|
||||||
aliases = {
|
aliases = {
|
||||||
"sub": "sub", "subscribe": "sub", "subscription": "sub", "subscriptions": "sub",
|
|
||||||
"unsub": "unsub", "unsubscribe": "unsub",
|
|
||||||
"mysubs": "mysubs", "subs": "mysubs",
|
|
||||||
"health": "health", "mesh": "health",
|
"health": "health", "mesh": "health",
|
||||||
"region": "region", "reg": "region",
|
"region": "region", "reg": "region",
|
||||||
"neighbors": "neighbors", "nbr": "neighbors", "nb": "neighbors",
|
"neighbors": "neighbors", "nbr": "neighbors", "nb": "neighbors",
|
||||||
|
|
@ -81,32 +70,6 @@ class HelpCommand(CommandHandler):
|
||||||
registered = {c.name.lower() for c in self._dispatcher.get_commands()}
|
registered = {c.name.lower() for c in self._dispatcher.get_commands()}
|
||||||
|
|
||||||
texts = {
|
texts = {
|
||||||
"sub": (
|
|
||||||
"Subscribe to Reports & Alerts\n\n"
|
|
||||||
"Daily report:\n"
|
|
||||||
" !sub daily 6pm\n"
|
|
||||||
" !sub daily 7:30am region SCID\n"
|
|
||||||
" !sub daily 6pm node MHR\n\n"
|
|
||||||
"Weekly digest:\n"
|
|
||||||
" !sub weekly 8am sun\n\n"
|
|
||||||
"Alerts (instant DM on issues):\n"
|
|
||||||
" !sub alerts\n"
|
|
||||||
" !sub alerts region Wood River\n\n"
|
|
||||||
"Time: 6pm, 6:30pm, 1830, 18:30\n"
|
|
||||||
"Regions: SCID, SWID, Magic Valley, Twin Falls\n\n"
|
|
||||||
"Manage:\n"
|
|
||||||
" !mysubs - list yours\n"
|
|
||||||
" !unsub daily - remove daily\n"
|
|
||||||
" !unsub all - remove everything"
|
|
||||||
),
|
|
||||||
"unsub": (
|
|
||||||
"Unsubscribe\n\n"
|
|
||||||
" !unsub daily - remove daily report\n"
|
|
||||||
" !unsub weekly - remove weekly digest\n"
|
|
||||||
" !unsub alerts - remove alerts\n"
|
|
||||||
" !unsub all - remove everything"
|
|
||||||
),
|
|
||||||
"mysubs": "!mysubs - list your active subscriptions",
|
|
||||||
"health": (
|
"health": (
|
||||||
"Mesh Health\n\n"
|
"Mesh Health\n\n"
|
||||||
" !health - 5-pillar health summary\n"
|
" !health - 5-pillar health summary\n"
|
||||||
|
|
|
||||||
|
|
@ -1,381 +0,0 @@
|
||||||
"""Subscription commands for scheduled reports and alerts."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from .base import CommandContext, CommandHandler
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..mesh_data_store import MeshDataStore
|
|
||||||
from ..mesh_reporter import MeshReporter
|
|
||||||
from ..subscriptions import SubscriptionManager
|
|
||||||
from ..notifications.router import NotificationRouter
|
|
||||||
|
|
||||||
|
|
||||||
class SubCommand(CommandHandler):
|
|
||||||
"""Subscribe to scheduled reports or alerts."""
|
|
||||||
|
|
||||||
name = "sub"
|
|
||||||
description = "Subscribe to reports or alerts"
|
|
||||||
usage = "!sub daily|weekly|alerts|<category> [time] [day] [scope]"
|
|
||||||
aliases = ["subscribe"]
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
subscription_manager: "SubscriptionManager" = None,
|
|
||||||
mesh_reporter: "MeshReporter" = None,
|
|
||||||
data_store: "MeshDataStore" = None,
|
|
||||||
notification_router: "NotificationRouter" = None,
|
|
||||||
):
|
|
||||||
self._sub_manager = subscription_manager
|
|
||||||
self._reporter = mesh_reporter
|
|
||||||
self._data_store = data_store
|
|
||||||
self._notification_router = notification_router
|
|
||||||
|
|
||||||
async def execute(self, args: str, context: CommandContext) -> str:
|
|
||||||
"""Handle subscription command."""
|
|
||||||
parts = args.strip().split()
|
|
||||||
|
|
||||||
# No args - show available alert categories
|
|
||||||
if not parts:
|
|
||||||
return self._show_categories()
|
|
||||||
|
|
||||||
sub_type = parts[0].lower()
|
|
||||||
|
|
||||||
# Check if it's a category subscription
|
|
||||||
if self._notification_router:
|
|
||||||
from ..notifications.categories import ALERT_CATEGORIES
|
|
||||||
if sub_type in ALERT_CATEGORIES or sub_type == "all":
|
|
||||||
return self._handle_category_subscription(sub_type, context)
|
|
||||||
|
|
||||||
# Legacy subscription types
|
|
||||||
if sub_type not in ("daily", "weekly", "alerts"):
|
|
||||||
return self._show_categories()
|
|
||||||
|
|
||||||
if not self._sub_manager:
|
|
||||||
return "Subscriptions not available."
|
|
||||||
|
|
||||||
try:
|
|
||||||
if sub_type == "daily":
|
|
||||||
return self._handle_daily(parts[1:], context)
|
|
||||||
elif sub_type == "weekly":
|
|
||||||
return self._handle_weekly(parts[1:], context)
|
|
||||||
else: # alerts
|
|
||||||
return self._handle_alerts(parts[1:], context)
|
|
||||||
except ValueError as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
def _show_categories(self) -> str:
|
|
||||||
"""Show available alert categories."""
|
|
||||||
try:
|
|
||||||
from ..notifications.categories import ALERT_CATEGORIES
|
|
||||||
except ImportError:
|
|
||||||
return self._usage_help()
|
|
||||||
|
|
||||||
lines = ["Available alert categories:"]
|
|
||||||
for cat_id, cat_info in ALERT_CATEGORIES.items():
|
|
||||||
lines.append(f" {cat_id} - {cat_info['description']}")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Usage:")
|
|
||||||
lines.append(" !sub <category> - subscribe to a category")
|
|
||||||
lines.append(" !sub all - subscribe to all alerts")
|
|
||||||
lines.append(" !sub alerts - legacy mesh-wide alerts")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def _handle_category_subscription(self, category: str, context: CommandContext) -> str:
|
|
||||||
"""Handle category-based alert subscription."""
|
|
||||||
node_id = self._get_user_id(context)
|
|
||||||
|
|
||||||
if category == "all":
|
|
||||||
categories = [] # Empty = all categories
|
|
||||||
else:
|
|
||||||
categories = [category]
|
|
||||||
|
|
||||||
# Add subscription via notification router
|
|
||||||
rule_name = self._notification_router.add_mesh_subscription(
|
|
||||||
node_id=node_id,
|
|
||||||
categories=categories,
|
|
||||||
)
|
|
||||||
|
|
||||||
if category == "all":
|
|
||||||
return "Subscribed to all alert categories. Use !unsub to remove."
|
|
||||||
else:
|
|
||||||
from ..notifications.categories import get_category
|
|
||||||
cat_info = get_category(category)
|
|
||||||
return f"Subscribed to {cat_info['name']} alerts. Use !unsub {category} to remove."
|
|
||||||
|
|
||||||
def _usage_help(self) -> str:
|
|
||||||
"""Return usage help."""
|
|
||||||
return """Usage:
|
|
||||||
!sub daily 1830 - daily mesh report at 6:30 PM
|
|
||||||
!sub daily 1830 region SCID - daily region report
|
|
||||||
!sub weekly 0800 sun - weekly digest Sunday 8 AM
|
|
||||||
!sub alerts - mesh-wide alerts (legacy)
|
|
||||||
!sub <category> - subscribe to alert category
|
|
||||||
!sub all - subscribe to all alerts"""
|
|
||||||
|
|
||||||
def _handle_daily(self, args: list, context: CommandContext) -> str:
|
|
||||||
"""Handle daily subscription."""
|
|
||||||
if not args:
|
|
||||||
raise ValueError("Time required. Example: !sub daily 1830")
|
|
||||||
|
|
||||||
schedule_time = args[0]
|
|
||||||
scope_type, scope_value = self._parse_scope(args[1:])
|
|
||||||
scope_value = self._validate_scope(scope_type, scope_value)
|
|
||||||
|
|
||||||
self._sub_manager.add(
|
|
||||||
user_id=self._get_user_id(context),
|
|
||||||
sub_type="daily",
|
|
||||||
schedule_time=schedule_time,
|
|
||||||
scope_type=scope_type,
|
|
||||||
scope_value=scope_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
time_fmt = self._format_time(schedule_time)
|
|
||||||
scope_desc = self._format_scope(scope_type, scope_value)
|
|
||||||
return f"Subscribed: daily {scope_desc}report at {time_fmt}"
|
|
||||||
|
|
||||||
def _handle_weekly(self, args: list, context: CommandContext) -> str:
|
|
||||||
"""Handle weekly subscription."""
|
|
||||||
if len(args) < 2:
|
|
||||||
raise ValueError("Time and day required. Example: !sub weekly 0800 sun")
|
|
||||||
|
|
||||||
schedule_time = args[0]
|
|
||||||
schedule_day = args[1].lower()
|
|
||||||
scope_type, scope_value = self._parse_scope(args[2:])
|
|
||||||
scope_value = self._validate_scope(scope_type, scope_value)
|
|
||||||
|
|
||||||
self._sub_manager.add(
|
|
||||||
user_id=self._get_user_id(context),
|
|
||||||
sub_type="weekly",
|
|
||||||
schedule_time=schedule_time,
|
|
||||||
schedule_day=schedule_day,
|
|
||||||
scope_type=scope_type,
|
|
||||||
scope_value=scope_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
time_fmt = self._format_time(schedule_time)
|
|
||||||
day_fmt = schedule_day.capitalize()
|
|
||||||
scope_desc = self._format_scope(scope_type, scope_value)
|
|
||||||
return f"Subscribed: weekly {scope_desc}report at {time_fmt} {day_fmt}"
|
|
||||||
|
|
||||||
def _handle_alerts(self, args: list, context: CommandContext) -> str:
|
|
||||||
"""Handle alerts subscription (legacy)."""
|
|
||||||
scope_type, scope_value = self._parse_scope(args)
|
|
||||||
scope_value = self._validate_scope(scope_type, scope_value)
|
|
||||||
|
|
||||||
self._sub_manager.add(
|
|
||||||
user_id=self._get_user_id(context),
|
|
||||||
sub_type="alerts",
|
|
||||||
scope_type=scope_type,
|
|
||||||
scope_value=scope_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
scope_desc = self._format_scope(scope_type, scope_value)
|
|
||||||
return f"Subscribed: alerts for {scope_desc.strip() or 'mesh'}"
|
|
||||||
|
|
||||||
def _parse_scope(self, args: list) -> tuple[str, str]:
|
|
||||||
"""Parse scope from remaining args."""
|
|
||||||
if not args:
|
|
||||||
return "mesh", None
|
|
||||||
|
|
||||||
scope_type = "mesh"
|
|
||||||
scope_value = None
|
|
||||||
|
|
||||||
for i, arg in enumerate(args):
|
|
||||||
arg_lower = arg.lower()
|
|
||||||
if arg_lower == "region":
|
|
||||||
scope_type = "region"
|
|
||||||
scope_value = " ".join(args[i + 1:]) if i + 1 < len(args) else None
|
|
||||||
break
|
|
||||||
elif arg_lower == "node":
|
|
||||||
scope_type = "node"
|
|
||||||
scope_value = args[i + 1] if i + 1 < len(args) else None
|
|
||||||
break
|
|
||||||
|
|
||||||
return scope_type, scope_value
|
|
||||||
|
|
||||||
def _validate_scope(self, scope_type: str, scope_value: str) -> str:
|
|
||||||
"""Validate and resolve scope value."""
|
|
||||||
if scope_type == "mesh":
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not scope_value:
|
|
||||||
raise ValueError(f"Missing {scope_type} name")
|
|
||||||
|
|
||||||
if scope_type == "region" and self._reporter:
|
|
||||||
region = self._reporter._find_region(scope_value)
|
|
||||||
if region:
|
|
||||||
return region.name
|
|
||||||
return scope_value
|
|
||||||
|
|
||||||
if scope_type == "node" and self._reporter:
|
|
||||||
node = self._reporter._find_node(scope_value)
|
|
||||||
if not node:
|
|
||||||
raise ValueError(f"Node '{scope_value}' not found")
|
|
||||||
return node.short_name or str(node.node_num)
|
|
||||||
|
|
||||||
return scope_value
|
|
||||||
|
|
||||||
def _get_user_id(self, context: CommandContext) -> str:
|
|
||||||
"""Extract user ID from context."""
|
|
||||||
sender_id = context.sender_id
|
|
||||||
if sender_id.startswith("!"):
|
|
||||||
return str(int(sender_id[1:], 16))
|
|
||||||
return sender_id
|
|
||||||
|
|
||||||
def _format_time(self, hhmm: str) -> str:
|
|
||||||
"""Format HHMM as readable time."""
|
|
||||||
hours = int(hhmm[:2])
|
|
||||||
minutes = int(hhmm[2:])
|
|
||||||
period = "AM" if hours < 12 else "PM"
|
|
||||||
display_hour = hours % 12 or 12
|
|
||||||
return f"{display_hour}:{minutes:02d} {period}"
|
|
||||||
|
|
||||||
def _format_scope(self, scope_type: str, scope_value: str) -> str:
|
|
||||||
"""Format scope for display."""
|
|
||||||
if scope_type == "mesh" or not scope_value:
|
|
||||||
return "mesh "
|
|
||||||
return f"{scope_type} {scope_value} "
|
|
||||||
|
|
||||||
|
|
||||||
class UnsubCommand(CommandHandler):
|
|
||||||
"""Unsubscribe from reports or alerts."""
|
|
||||||
|
|
||||||
name = "unsub"
|
|
||||||
description = "Remove subscription(s)"
|
|
||||||
usage = "!unsub daily|weekly|alerts|<category>|all"
|
|
||||||
aliases = ["unsubscribe"]
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
subscription_manager: "SubscriptionManager" = None,
|
|
||||||
notification_router: "NotificationRouter" = None,
|
|
||||||
):
|
|
||||||
self._sub_manager = subscription_manager
|
|
||||||
self._notification_router = notification_router
|
|
||||||
|
|
||||||
async def execute(self, args: str, context: CommandContext) -> str:
|
|
||||||
"""Handle unsubscribe command."""
|
|
||||||
sub_type = args.strip().lower() if args else None
|
|
||||||
|
|
||||||
if not sub_type:
|
|
||||||
return "Usage: !unsub daily|weekly|alerts|<category>|all"
|
|
||||||
|
|
||||||
user_id = self._get_user_id(context)
|
|
||||||
|
|
||||||
# Check if it's a category unsubscription
|
|
||||||
if self._notification_router:
|
|
||||||
from ..notifications.categories import ALERT_CATEGORIES
|
|
||||||
if sub_type in ALERT_CATEGORIES or sub_type == "all":
|
|
||||||
self._notification_router.remove_mesh_subscription(user_id)
|
|
||||||
return "Removed alert subscriptions"
|
|
||||||
|
|
||||||
# Legacy subscription types
|
|
||||||
if not self._sub_manager:
|
|
||||||
return "Subscriptions not available."
|
|
||||||
|
|
||||||
if sub_type not in ("daily", "weekly", "alerts", "all"):
|
|
||||||
return f"Invalid type '{sub_type}'. Use: daily, weekly, alerts, <category>, or all"
|
|
||||||
|
|
||||||
removed = self._sub_manager.remove(user_id, sub_type if sub_type != "all" else None)
|
|
||||||
|
|
||||||
if removed == 0:
|
|
||||||
return "No subscriptions found to remove"
|
|
||||||
elif sub_type == "all":
|
|
||||||
return f"Removed all {removed} subscription(s)"
|
|
||||||
else:
|
|
||||||
return f"Removed {removed} {sub_type} subscription(s)"
|
|
||||||
|
|
||||||
def _get_user_id(self, context: CommandContext) -> str:
|
|
||||||
"""Extract user ID from context."""
|
|
||||||
sender_id = context.sender_id
|
|
||||||
if sender_id.startswith("!"):
|
|
||||||
return str(int(sender_id[1:], 16))
|
|
||||||
return sender_id
|
|
||||||
|
|
||||||
|
|
||||||
class MySubsCommand(CommandHandler):
|
|
||||||
"""List active subscriptions."""
|
|
||||||
|
|
||||||
name = "mysubs"
|
|
||||||
description = "List your subscriptions"
|
|
||||||
usage = "!mysubs"
|
|
||||||
aliases = ["subs", "subscriptions"]
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
subscription_manager: "SubscriptionManager" = None,
|
|
||||||
notification_router: "NotificationRouter" = None,
|
|
||||||
):
|
|
||||||
self._sub_manager = subscription_manager
|
|
||||||
self._notification_router = notification_router
|
|
||||||
|
|
||||||
async def execute(self, args: str, context: CommandContext) -> str:
|
|
||||||
"""List user's subscriptions."""
|
|
||||||
user_id = self._get_user_id(context)
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
# Check notification router subscriptions
|
|
||||||
if self._notification_router:
|
|
||||||
categories = self._notification_router.get_node_subscriptions(user_id)
|
|
||||||
if categories:
|
|
||||||
if categories == ["all"]:
|
|
||||||
lines.append("Alert subscriptions: all categories")
|
|
||||||
else:
|
|
||||||
lines.append(f"Alert subscriptions: {', '.join(categories)}")
|
|
||||||
|
|
||||||
# Check legacy subscriptions
|
|
||||||
if self._sub_manager:
|
|
||||||
subs = self._sub_manager.get_user_subs(user_id)
|
|
||||||
if subs:
|
|
||||||
if not lines:
|
|
||||||
lines.append("Your subscriptions:")
|
|
||||||
else:
|
|
||||||
lines.append("\nScheduled reports:")
|
|
||||||
for i, sub in enumerate(subs, 1):
|
|
||||||
lines.append(f" {i}. {self._format_sub(sub)}")
|
|
||||||
|
|
||||||
if not lines:
|
|
||||||
return "No active subscriptions. Use !sub to subscribe."
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def _format_sub(self, sub: dict) -> str:
|
|
||||||
"""Format a subscription for display."""
|
|
||||||
sub_type = sub["sub_type"]
|
|
||||||
scope_type = sub.get("scope_type", "mesh")
|
|
||||||
scope_value = sub.get("scope_value")
|
|
||||||
|
|
||||||
scope_desc = ""
|
|
||||||
if scope_type == "region" and scope_value:
|
|
||||||
scope_desc = f"region {scope_value} "
|
|
||||||
elif scope_type == "node" and scope_value:
|
|
||||||
scope_desc = f"node {scope_value} "
|
|
||||||
|
|
||||||
if sub_type == "daily":
|
|
||||||
time_str = self._format_time(sub.get("schedule_time", "0000"))
|
|
||||||
return f"Daily {scope_desc}report at {time_str}"
|
|
||||||
elif sub_type == "weekly":
|
|
||||||
time_str = self._format_time(sub.get("schedule_time", "0000"))
|
|
||||||
day_str = (sub.get("schedule_day") or "").capitalize()
|
|
||||||
return f"Weekly {scope_desc}report at {time_str} {day_str}"
|
|
||||||
else:
|
|
||||||
return f"Alerts for {scope_desc.strip() or 'mesh'}"
|
|
||||||
|
|
||||||
def _format_time(self, hhmm: str) -> str:
|
|
||||||
"""Format HHMM as readable time."""
|
|
||||||
if not hhmm or len(hhmm) != 4:
|
|
||||||
return hhmm
|
|
||||||
hours = int(hhmm[:2])
|
|
||||||
minutes = int(hhmm[2:])
|
|
||||||
period = "AM" if hours < 12 else "PM"
|
|
||||||
display_hour = hours % 12 or 12
|
|
||||||
return f"{display_hour}:{minutes:02d} {period}"
|
|
||||||
|
|
||||||
def _get_user_id(self, context: CommandContext) -> str:
|
|
||||||
"""Extract user ID from context."""
|
|
||||||
sender_id = context.sender_id
|
|
||||||
if sender_id.startswith("!"):
|
|
||||||
return str(int(sender_id[1:], 16))
|
|
||||||
return sender_id
|
|
||||||
|
|
@ -56,31 +56,29 @@ async def get_alert_history(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subscriptions")
|
@router.get("/activity")
|
||||||
async def get_subscriptions(request: Request):
|
async def get_activity(
|
||||||
"""Get all alert subscriptions."""
|
request: Request,
|
||||||
subscription_manager = getattr(request.app.state, "subscription_manager", None)
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
):
|
||||||
|
"""Activity Log: most recent outbound mesh broadcasts, newest first.
|
||||||
|
|
||||||
if not subscription_manager:
|
Reads mesh_broadcasts_out from the persistence/migration DB (get_db) and
|
||||||
return []
|
returns every column as a plain dict. Legacy rows keep NULL
|
||||||
|
transport/success. If the table doesn't exist yet, returns [].
|
||||||
|
"""
|
||||||
|
from meshai.persistence import get_db
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subs = subscription_manager.get_all_subs()
|
conn = get_db()
|
||||||
return [
|
rows = conn.execute(
|
||||||
{
|
"SELECT * FROM mesh_broadcasts_out "
|
||||||
"id": sub["id"],
|
"ORDER BY sent_at DESC, id DESC LIMIT ?",
|
||||||
"user_id": sub["user_id"],
|
(limit,),
|
||||||
"sub_type": sub["sub_type"],
|
).fetchall()
|
||||||
"schedule_time": sub.get("schedule_time"),
|
|
||||||
"schedule_day": sub.get("schedule_day"),
|
|
||||||
"scope_type": sub.get("scope_type", "mesh"),
|
|
||||||
"scope_value": sub.get("scope_value"),
|
|
||||||
"enabled": sub.get("enabled", 1) == 1,
|
|
||||||
}
|
|
||||||
for sub in subs
|
|
||||||
]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def _map_severity(alert: dict) -> str:
|
def _map_severity(alert: dict) -> str:
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,6 @@ async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster:
|
||||||
app.state.health_engine = meshai_instance.health_engine
|
app.state.health_engine = meshai_instance.health_engine
|
||||||
app.state.alert_engine = getattr(meshai_instance, "alert_engine", None)
|
app.state.alert_engine = getattr(meshai_instance, "alert_engine", None)
|
||||||
app.state.env_store = getattr(meshai_instance, "env_store", None)
|
app.state.env_store = getattr(meshai_instance, "env_store", None)
|
||||||
app.state.subscription_manager = meshai_instance.subscription_manager
|
|
||||||
app.state.notification_router = getattr(meshai_instance, "notification_router", None)
|
app.state.notification_router = getattr(meshai_instance, "notification_router", None)
|
||||||
app.state.connector = meshai_instance.connector
|
app.state.connector = meshai_instance.connector
|
||||||
app.state.bus = getattr(meshai_instance, "event_bus", None)
|
app.state.bus = getattr(meshai_instance, "event_bus", None)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ class MeshAI:
|
||||||
self.data_store = None # Replaces source_manager
|
self.data_store = None # Replaces source_manager
|
||||||
self.health_engine = None
|
self.health_engine = None
|
||||||
self.mesh_reporter = None
|
self.mesh_reporter = None
|
||||||
self.subscription_manager = None
|
|
||||||
self.alert_engine = None
|
self.alert_engine = None
|
||||||
self.notification_router = None
|
self.notification_router = None
|
||||||
self.event_bus = None # Notification pipeline EventBus (v0.3)
|
self.event_bus = None # Notification pipeline EventBus (v0.3)
|
||||||
|
|
@ -53,7 +52,6 @@ class MeshAI:
|
||||||
self.env_store = None # Environmental feeds store
|
self.env_store = None # Environmental feeds store
|
||||||
self._central_consumer = None # Central NATS consumer (v0.4)
|
self._central_consumer = None # Central NATS consumer (v0.4)
|
||||||
self._fire_pacer = None # FirePacer for rate-limited fire broadcasts
|
self._fire_pacer = None # FirePacer for rate-limited fire broadcasts
|
||||||
self._last_sub_check: float = 0.0
|
|
||||||
self.router: Optional[MessageRouter] = None
|
self.router: Optional[MessageRouter] = None
|
||||||
self.responder: Optional[Responder] = None
|
self.responder: Optional[Responder] = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
@ -223,12 +221,6 @@ class MeshAI:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Env refresh error: %s", e)
|
logger.debug("Env refresh error: %s", e)
|
||||||
|
|
||||||
# Check scheduled subscriptions (every 60 seconds)
|
|
||||||
if self.subscription_manager and self.mesh_reporter:
|
|
||||||
if time.time() - self._last_sub_check >= 60:
|
|
||||||
await self._check_scheduled_subs()
|
|
||||||
self._last_sub_check = time.time()
|
|
||||||
|
|
||||||
# Periodic cleanup
|
# Periodic cleanup
|
||||||
if time.time() - self._last_cleanup >= 3600:
|
if time.time() - self._last_cleanup >= 3600:
|
||||||
await self.history.cleanup_expired()
|
await self.history.cleanup_expired()
|
||||||
|
|
@ -326,8 +318,6 @@ class MeshAI:
|
||||||
if self.data_store:
|
if self.data_store:
|
||||||
await self.data_store.stop_mqtt_sources()
|
await self.data_store.stop_mqtt_sources()
|
||||||
self.data_store.close()
|
self.data_store.close()
|
||||||
if self.subscription_manager:
|
|
||||||
self.subscription_manager.close()
|
|
||||||
|
|
||||||
self._remove_pid()
|
self._remove_pid()
|
||||||
logger.info("MeshAI stopped")
|
logger.info("MeshAI stopped")
|
||||||
|
|
@ -497,22 +487,13 @@ class MeshAI:
|
||||||
else:
|
else:
|
||||||
self.mesh_reporter = None
|
self.mesh_reporter = None
|
||||||
|
|
||||||
# Subscription manager (uses same db as data_store)
|
# Alert engine (needs health engine and reporter)
|
||||||
if self.data_store:
|
if self.health_engine and self.mesh_reporter:
|
||||||
from .subscriptions import SubscriptionManager
|
|
||||||
self.subscription_manager = SubscriptionManager(db_path="/data/mesh_history.db")
|
|
||||||
logger.info("Subscription manager enabled")
|
|
||||||
else:
|
|
||||||
self.subscription_manager = None
|
|
||||||
|
|
||||||
# Alert engine (needs health engine, reporter, and subscription manager)
|
|
||||||
if self.health_engine and self.mesh_reporter and self.subscription_manager:
|
|
||||||
from .alert_engine import AlertEngine
|
from .alert_engine import AlertEngine
|
||||||
mi = self.config.mesh_intelligence
|
mi = self.config.mesh_intelligence
|
||||||
self.alert_engine = AlertEngine(
|
self.alert_engine = AlertEngine(
|
||||||
health_engine=self.health_engine,
|
health_engine=self.health_engine,
|
||||||
reporter=self.mesh_reporter,
|
reporter=self.mesh_reporter,
|
||||||
subscription_manager=self.subscription_manager,
|
|
||||||
config=mi,
|
config=mi,
|
||||||
db_path="/data/mesh_history.db",
|
db_path="/data/mesh_history.db",
|
||||||
timezone=self.config.timezone,
|
timezone=self.config.timezone,
|
||||||
|
|
@ -613,9 +594,7 @@ class MeshAI:
|
||||||
mesh_reporter=self.mesh_reporter,
|
mesh_reporter=self.mesh_reporter,
|
||||||
data_store=self.data_store,
|
data_store=self.data_store,
|
||||||
health_engine=self.health_engine,
|
health_engine=self.health_engine,
|
||||||
subscription_manager=self.subscription_manager,
|
|
||||||
env_store=self.env_store,
|
env_store=self.env_store,
|
||||||
notification_router=self.notification_router,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Message router
|
# Message router
|
||||||
|
|
@ -799,94 +778,9 @@ class MeshAI:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send channel alert: {e}")
|
logger.error(f"Failed to send channel alert: {e}")
|
||||||
|
|
||||||
# Fallback: Send DMs to matching subscribers
|
|
||||||
if self.alert_engine and self.subscription_manager:
|
|
||||||
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
|
|
||||||
for sub in subscribers:
|
|
||||||
user_id = sub["user_id"]
|
|
||||||
try:
|
|
||||||
await self._send_sub_dm(user_id, message)
|
|
||||||
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send alert DM to {user_id}: {e}")
|
|
||||||
|
|
||||||
if self.alert_engine:
|
if self.alert_engine:
|
||||||
self.alert_engine.clear_pending()
|
self.alert_engine.clear_pending()
|
||||||
|
|
||||||
async def _check_scheduled_subs(self) -> None:
|
|
||||||
"""Check for and deliver due scheduled reports."""
|
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
tz = ZoneInfo(self.config.timezone)
|
|
||||||
now = datetime.now(tz)
|
|
||||||
current_hhmm = now.strftime("%H%M")
|
|
||||||
current_day = now.strftime("%a").lower()
|
|
||||||
|
|
||||||
due_subs = self.subscription_manager.get_due_subscriptions(current_hhmm, current_day)
|
|
||||||
|
|
||||||
for sub in due_subs:
|
|
||||||
try:
|
|
||||||
# Generate report based on scope
|
|
||||||
report = self._generate_sub_report(sub)
|
|
||||||
if not report:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Send DM to subscriber
|
|
||||||
user_id = sub["user_id"]
|
|
||||||
await self._send_sub_dm(user_id, report)
|
|
||||||
|
|
||||||
# Mark as sent
|
|
||||||
self.subscription_manager.mark_sent(sub["id"])
|
|
||||||
logger.info(f"Delivered {sub['sub_type']} report to {user_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error delivering subscription {sub['id']}: {e}")
|
|
||||||
|
|
||||||
def _generate_sub_report(self, sub: dict) -> str:
|
|
||||||
"""Generate report content for a subscription."""
|
|
||||||
if not self.mesh_reporter:
|
|
||||||
return None
|
|
||||||
|
|
||||||
sub_type = sub["sub_type"]
|
|
||||||
scope_type = sub.get("scope_type", "mesh")
|
|
||||||
scope_value = sub.get("scope_value")
|
|
||||||
|
|
||||||
if scope_type == "region" and scope_value:
|
|
||||||
# Region-scoped report
|
|
||||||
region = self.mesh_reporter._find_region(scope_value)
|
|
||||||
if region:
|
|
||||||
return self.mesh_reporter.build_region_compact(region.name)
|
|
||||||
return None
|
|
||||||
elif scope_type == "node" and scope_value:
|
|
||||||
# Node-scoped report
|
|
||||||
return self.mesh_reporter.build_node_compact(scope_value)
|
|
||||||
else:
|
|
||||||
# Mesh-wide report
|
|
||||||
return self.mesh_reporter.build_lora_compact(scope="mesh")
|
|
||||||
|
|
||||||
async def _send_sub_dm(self, node_num: str, message: str) -> None:
|
|
||||||
"""Send a subscription DM to a node."""
|
|
||||||
if not self.connector:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Convert node_num to destination format
|
|
||||||
try:
|
|
||||||
dest = int(node_num)
|
|
||||||
except ValueError:
|
|
||||||
dest = node_num
|
|
||||||
|
|
||||||
# Send via responder for proper chunking
|
|
||||||
if self.responder:
|
|
||||||
await self.responder.send_response(
|
|
||||||
message,
|
|
||||||
destination=dest,
|
|
||||||
channel=0, # DM channel
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fallback to direct send
|
|
||||||
self.connector.send_message(message, destination=dest)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(verbose: bool = False) -> None:
|
def setup_logging(verbose: bool = False) -> None:
|
||||||
"""Configure logging."""
|
"""Configure logging."""
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,8 @@ class Dispatcher:
|
||||||
|
|
||||||
delivered_any = False
|
delivered_any = False
|
||||||
for ch_type in ch_types:
|
for ch_type in ch_types:
|
||||||
|
rule = None
|
||||||
|
payload = None
|
||||||
try:
|
try:
|
||||||
rule = self._toggle_to_rule(tog, ch_type, event)
|
rule = self._toggle_to_rule(tog, ch_type, event)
|
||||||
channel = self._channel_factory(rule, self._connector)
|
channel = self._channel_factory(rule, self._connector)
|
||||||
|
|
@ -458,15 +460,20 @@ class Dispatcher:
|
||||||
if success:
|
if success:
|
||||||
delivered_any = True
|
delivered_any = True
|
||||||
self._logger.info(f"Dispatched event {event.id} via toggle {fam}/{ch_type}")
|
self._logger.info(f"Dispatched event {event.id} via toggle {fam}/{ch_type}")
|
||||||
# v0.5.8b post-broadcast commit. Persistence-side
|
|
||||||
# bookkeeping that should only happen when a delivery
|
|
||||||
# actually went out: mesh_broadcasts_out audit row +
|
|
||||||
# handler-supplied last_broadcast_* UPDATE callback.
|
|
||||||
self._post_broadcast_commit(event, payload, rule, ch_type)
|
|
||||||
else:
|
else:
|
||||||
self._logger.warning(f"Toggle channel delivery returned False for {fam}/{ch_type}")
|
self._logger.warning(f"Toggle channel delivery returned False for {fam}/{ch_type}")
|
||||||
|
# v0.5.8b post-broadcast commit -> v20 per-mesh audit.
|
||||||
|
# Written ONCE PER MESH CHANNEL with its own transport+success,
|
||||||
|
# so a fan-out to both meshes yields two rows and a skip
|
||||||
|
# (deliver()==False) is still visible as success=0. The
|
||||||
|
# last_broadcast_* callback fires only when success is truthy.
|
||||||
|
self._post_broadcast_commit(event, payload, rule, ch_type,
|
||||||
|
success=bool(success))
|
||||||
except Exception:
|
except Exception:
|
||||||
self._logger.exception(f"Toggle channel delivery failed for {fam}/{ch_type}")
|
self._logger.exception(f"Toggle channel delivery failed for {fam}/{ch_type}")
|
||||||
|
# A crashed delivery is still a failed send -> success=0 row.
|
||||||
|
self._post_broadcast_commit(event, payload, rule, ch_type,
|
||||||
|
success=False)
|
||||||
|
|
||||||
# ---------- Section 6 — guard commit (v0.6-4, B13 fix) ----------
|
# ---------- Section 6 — guard commit (v0.6-4, B13 fix) ----------
|
||||||
# Cooldown arming + dedup recording happen ONLY after at least one
|
# Cooldown arming + dedup recording happen ONLY after at least one
|
||||||
|
|
@ -600,39 +607,75 @@ class Dispatcher:
|
||||||
success = await channel.deliver(payload, rule)
|
success = await channel.deliver(payload, rule)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._logger.exception(
|
self._logger.exception(
|
||||||
"scheduled-broadcast: delivery raised for %s; skipping", ch_type)
|
"scheduled-broadcast: delivery raised for %s", ch_type)
|
||||||
continue
|
success = False
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
delivered_any = True
|
delivered_any = True
|
||||||
# Audit row -- mirrors _post_broadcast_commit for scheduled.
|
|
||||||
|
# v20 per-mesh audit row. Written once per mesh channel with its
|
||||||
|
# own transport+success, so a fan-out to both meshes yields two
|
||||||
|
# rows and a skip (deliver()==False) is visible as success=0.
|
||||||
try:
|
try:
|
||||||
from meshai.persistence import get_db
|
from meshai.persistence import get_db
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
bytes_sent = len(text.encode("utf-8")) if text else 0
|
bytes_sent = len(text.encode("utf-8")) if text else 0
|
||||||
|
transport, channel_id, recipient = self._audit_route(rule, ch_type)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, "
|
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, "
|
||||||
"channel, text, source_event_table, source_event_pk, "
|
"channel, text, source_event_table, source_event_pk, "
|
||||||
"bytes_sent, ack_received) VALUES (?,?,?,?,?,?,?,?)",
|
"bytes_sent, ack_received, transport, success) "
|
||||||
(int(time.time()), "broadcast",
|
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
rf.broadcast_channel, text,
|
(int(time.time()), recipient,
|
||||||
|
channel_id, text,
|
||||||
source_event_table, str(source_event_pk),
|
source_event_table, str(source_event_pk),
|
||||||
bytes_sent, 0),
|
bytes_sent, 0,
|
||||||
|
transport, 1 if success else 0),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._logger.exception(
|
self._logger.exception(
|
||||||
"scheduled-broadcast: audit row insert failed for %s", ch_type)
|
"scheduled-broadcast: audit row insert failed for %s", ch_type)
|
||||||
return delivered_any
|
return delivered_any
|
||||||
|
|
||||||
def _post_broadcast_commit(self, event, payload, rule, ch_type: str) -> None:
|
@staticmethod
|
||||||
"""Persistence side-effects of an actually-successful broadcast.
|
def _audit_route(rule, ch_type: str):
|
||||||
|
"""Resolve (transport, channel_id, recipient) for a mesh delivery.
|
||||||
|
|
||||||
Inserts the mesh_broadcasts_out audit row when the handler signalled
|
transport is the mesh family the row belongs to ("meshtastic" /
|
||||||
it wants one via `event.data["_broadcast_audit"]`, then invokes the
|
"meshcore"); channel_id is the Meshtastic channel INDEX or the
|
||||||
handler-supplied `_on_broadcast_committed` callback so the handler
|
MeshCore channel NAME; recipient is 'broadcast' or the DM target
|
||||||
can refresh its own last_broadcast_* bookkeeping. Both calls are
|
list. Mirrors create_channel()'s delivery_type routing.
|
||||||
wrapped: a bookkeeping failure must NOT undo the actual broadcast
|
"""
|
||||||
nor break dispatch for sibling toggles.
|
if ch_type == "mesh_broadcast":
|
||||||
|
return "meshtastic", getattr(rule, "broadcast_channel", None), "broadcast"
|
||||||
|
if ch_type == "meshcore_broadcast":
|
||||||
|
return "meshcore", getattr(rule, "meshcore_channel", None), "broadcast"
|
||||||
|
if ch_type == "mesh_dm":
|
||||||
|
node_ids = list(getattr(rule, "node_ids", []) or [])
|
||||||
|
return "meshtastic", None, (",".join(map(str, node_ids)) or "dm")
|
||||||
|
if ch_type == "meshcore_dm":
|
||||||
|
contacts = list(getattr(rule, "meshcore_dm_contacts", []) or [])
|
||||||
|
return "meshcore", None, (",".join(map(str, contacts)) or "meshcore_dm")
|
||||||
|
# Unknown / non-mesh: leave transport NULL, fall back to legacy channel.
|
||||||
|
return None, getattr(rule, "broadcast_channel", None), "broadcast"
|
||||||
|
|
||||||
|
def _post_broadcast_commit(self, event, payload, rule, ch_type: str,
|
||||||
|
*, success: bool = True) -> None:
|
||||||
|
"""Persistence side-effects of a per-mesh broadcast delivery.
|
||||||
|
|
||||||
|
Called ONCE PER MESH CHANNEL (one per delivery_type family), so a
|
||||||
|
broadcast that fans to both meshes writes TWO mesh_broadcasts_out
|
||||||
|
rows -- each carrying its own `transport` + `success` flag. The row
|
||||||
|
is written whenever the handler signalled it wants an audit trail
|
||||||
|
via `event.data["_broadcast_audit"]`, REGARDLESS of success, so a
|
||||||
|
skip/failure (e.g. MeshCore channel-not-found -> deliver()==False)
|
||||||
|
is still visible as success=0.
|
||||||
|
|
||||||
|
The handler-supplied `_on_broadcast_committed` callback (which
|
||||||
|
refreshes last_broadcast_* bookkeeping) fires ONLY when the send
|
||||||
|
actually landed (success is truthy). Both calls are wrapped: a
|
||||||
|
bookkeeping failure must NOT undo the actual broadcast nor break
|
||||||
|
dispatch for sibling toggles.
|
||||||
"""
|
"""
|
||||||
data = getattr(event, "data", None) or {}
|
data = getattr(event, "data", None) or {}
|
||||||
if not data:
|
if not data:
|
||||||
|
|
@ -646,23 +689,17 @@ class Dispatcher:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
text = payload.message if payload is not None else (event.title or "")
|
text = payload.message if payload is not None else (event.title or "")
|
||||||
bytes_sent = len(text.encode("utf-8")) if text else 0
|
bytes_sent = len(text.encode("utf-8")) if text else 0
|
||||||
if ch_type == "mesh_dm":
|
transport, channel, recipient = self._audit_route(rule, ch_type)
|
||||||
node_ids = list(getattr(rule, "node_ids", []) or [])
|
|
||||||
recipient = ",".join(map(str, node_ids)) or "dm"
|
|
||||||
elif ch_type == "meshcore_dm":
|
|
||||||
contacts = list(getattr(rule, "meshcore_dm_contacts", []) or [])
|
|
||||||
recipient = ",".join(map(str, contacts)) or "meshcore_dm"
|
|
||||||
else:
|
|
||||||
recipient = "broadcast"
|
|
||||||
channel = getattr(rule, "broadcast_channel", None)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, channel, "
|
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, channel, "
|
||||||
"text, source_event_table, source_event_pk, bytes_sent, "
|
"text, source_event_table, source_event_pk, bytes_sent, "
|
||||||
"ack_received) VALUES (?,?,?,?,?,?,?,?)",
|
"ack_received, transport, success) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
int(committed_at), recipient, channel, text,
|
int(committed_at), recipient, channel, text,
|
||||||
audit.get("table"), audit.get("pk"),
|
audit.get("table"), audit.get("pk"),
|
||||||
bytes_sent, 0,
|
bytes_sent, 0,
|
||||||
|
transport, 1 if success else 0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -672,6 +709,11 @@ class Dispatcher:
|
||||||
audit.get("table"), audit.get("pk"),
|
audit.get("table"), audit.get("pk"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
# A failed/skipped send is audited above but must NOT arm the
|
||||||
|
# handler's last_broadcast_* bookkeeping.
|
||||||
|
return
|
||||||
|
|
||||||
cb = data.get("_on_broadcast_committed")
|
cb = data.get("_on_broadcast_committed")
|
||||||
if callable(cb):
|
if callable(cb):
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -735,45 +735,6 @@ class NotificationRouter:
|
||||||
|
|
||||||
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
|
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
|
||||||
|
|
||||||
def add_mesh_subscription(self, node_id: str, categories: list[str], rule_name: Optional[str] = None) -> str:
|
|
||||||
"""Add a mesh DM subscription for a node."""
|
|
||||||
if not rule_name:
|
|
||||||
rule_name = "sub_%s" % node_id
|
|
||||||
|
|
||||||
for rule in self._rules:
|
|
||||||
if rule.get("name") == rule_name:
|
|
||||||
rule["categories"] = categories if categories else []
|
|
||||||
rule["node_ids"] = [node_id]
|
|
||||||
return rule_name
|
|
||||||
|
|
||||||
self._rules.append({
|
|
||||||
"name": rule_name,
|
|
||||||
"enabled": True,
|
|
||||||
"trigger_type": "condition",
|
|
||||||
"categories": categories if categories else [],
|
|
||||||
"min_severity": "priority",
|
|
||||||
"delivery_type": "mesh_dm",
|
|
||||||
"node_ids": [node_id],
|
|
||||||
"cooldown_minutes": 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
return rule_name
|
|
||||||
|
|
||||||
def remove_mesh_subscription(self, node_id: str) -> bool:
|
|
||||||
"""Remove a mesh subscription for a node."""
|
|
||||||
rule_name = "sub_%s" % node_id
|
|
||||||
self._rules = [r for r in self._rules if r.get("name") != rule_name]
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_node_subscriptions(self, node_id: str) -> list[str]:
|
|
||||||
"""Get categories a node is subscribed to."""
|
|
||||||
rule_name = "sub_%s" % node_id
|
|
||||||
for rule in self._rules:
|
|
||||||
if rule.get("name") == rule_name:
|
|
||||||
categories = rule.get("categories", [])
|
|
||||||
return categories if categories else ["all"]
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def generate_report(self, report_type: str, env_store, health_engine) -> str:
|
async def generate_report(self, report_type: str, env_store, health_engine) -> str:
|
||||||
"""Generate an LLM-summarized report from current data."""
|
"""Generate an LLM-summarized report from current data."""
|
||||||
context_parts = []
|
context_parts = []
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
||||||
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
||||||
SCHEMA_VERSION = 19
|
SCHEMA_VERSION = 20
|
||||||
SCHEMA_META_TABLE = "schema_meta"
|
SCHEMA_META_TABLE = "schema_meta"
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
|
||||||
9
work/meshai/persistence/migrations/v20.sql
Normal file
9
work/meshai/persistence/migrations/v20.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- v20: per-mesh broadcast audit. transport + success columns on
|
||||||
|
-- mesh_broadcasts_out so each SEND records which mesh it went to and
|
||||||
|
-- whether it landed. A broadcast fanning to BOTH meshes writes one row
|
||||||
|
-- per mesh, each with its own success flag.
|
||||||
|
-- Nullable, no index, no backfill. Legacy rows keep NULL transport/success
|
||||||
|
-- (Activity Log treats NULL as legacy/meshtastic-unknown).
|
||||||
|
|
||||||
|
ALTER TABLE mesh_broadcasts_out ADD COLUMN transport TEXT;
|
||||||
|
ALTER TABLE mesh_broadcasts_out ADD COLUMN success INTEGER;
|
||||||
|
|
@ -1,278 +0,0 @@
|
||||||
"""Subscription management for scheduled reports and alerts."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Valid subscription types
|
|
||||||
VALID_SUB_TYPES = {"daily", "weekly", "alerts"}
|
|
||||||
VALID_DAYS = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
|
|
||||||
VALID_SCOPE_TYPES = {"mesh", "region", "node"}
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionManager:
|
|
||||||
"""Manages user subscriptions with SQLite storage."""
|
|
||||||
|
|
||||||
def __init__(self, db_path: str):
|
|
||||||
"""Initialize subscription manager.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to SQLite database (same as mesh_history.db)
|
|
||||||
"""
|
|
||||||
self._db_path = db_path
|
|
||||||
self._db: Optional[sqlite3.Connection] = None
|
|
||||||
self._init_db()
|
|
||||||
|
|
||||||
def _init_db(self):
|
|
||||||
"""Initialize database connection and schema."""
|
|
||||||
self._db = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
||||||
self._db.row_factory = sqlite3.Row
|
|
||||||
|
|
||||||
self._db.executescript("""
|
|
||||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
sub_type TEXT NOT NULL,
|
|
||||||
schedule_time TEXT,
|
|
||||||
schedule_day TEXT,
|
|
||||||
scope_type TEXT DEFAULT 'mesh',
|
|
||||||
scope_value TEXT,
|
|
||||||
created_at REAL NOT NULL,
|
|
||||||
last_sent REAL DEFAULT 0,
|
|
||||||
enabled INTEGER DEFAULT 1
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sub_user ON subscriptions(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sub_type ON subscriptions(sub_type);
|
|
||||||
""")
|
|
||||||
self._db.commit()
|
|
||||||
logger.info("Subscription manager initialized")
|
|
||||||
|
|
||||||
def _row_to_dict(self, row: sqlite3.Row) -> dict:
|
|
||||||
"""Convert sqlite Row to dict."""
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
def add(self, user_id: str, sub_type: str, schedule_time: str = None,
|
|
||||||
schedule_day: str = None, scope_type: str = "mesh",
|
|
||||||
scope_value: str = None) -> dict:
|
|
||||||
"""Add a subscription.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: Subscriber node_num
|
|
||||||
sub_type: "daily", "weekly", or "alerts"
|
|
||||||
schedule_time: HHMM format (required for daily/weekly)
|
|
||||||
schedule_day: mon-sun (required for weekly)
|
|
||||||
scope_type: "mesh", "region", or "node"
|
|
||||||
scope_value: Region name or node identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created subscription dict
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If validation fails
|
|
||||||
"""
|
|
||||||
# Validate sub_type
|
|
||||||
if sub_type not in VALID_SUB_TYPES:
|
|
||||||
raise ValueError(f"Invalid type '{sub_type}'. Use: daily, weekly, or alerts")
|
|
||||||
|
|
||||||
# Validate schedule_time for daily/weekly
|
|
||||||
if sub_type in ("daily", "weekly"):
|
|
||||||
if not schedule_time:
|
|
||||||
raise ValueError(f"Time required for {sub_type} subscription. Use HHMM format (e.g., 1830)")
|
|
||||||
if not self._validate_time(schedule_time):
|
|
||||||
raise ValueError("Invalid time format. Use HHMM (e.g., 1830 for 6:30 PM)")
|
|
||||||
|
|
||||||
# Validate schedule_day for weekly
|
|
||||||
if sub_type == "weekly":
|
|
||||||
if not schedule_day:
|
|
||||||
raise ValueError("Day required for weekly subscription. Use: mon, tue, wed, thu, fri, sat, sun")
|
|
||||||
if schedule_day.lower() not in VALID_DAYS:
|
|
||||||
raise ValueError("Invalid day. Use: mon, tue, wed, thu, fri, sat, sun")
|
|
||||||
schedule_day = schedule_day.lower()
|
|
||||||
|
|
||||||
# Validate scope_type
|
|
||||||
if scope_type not in VALID_SCOPE_TYPES:
|
|
||||||
raise ValueError(f"Invalid scope '{scope_type}'. Use: mesh, region, or node")
|
|
||||||
|
|
||||||
# Check for duplicates
|
|
||||||
existing = self._db.execute("""
|
|
||||||
SELECT id FROM subscriptions
|
|
||||||
WHERE user_id = ? AND sub_type = ? AND scope_type = ?
|
|
||||||
AND (scope_value = ? OR (scope_value IS NULL AND ? IS NULL))
|
|
||||||
AND enabled = 1
|
|
||||||
""", (user_id, sub_type, scope_type, scope_value, scope_value)).fetchone()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
scope_desc = f" for {scope_type} {scope_value}" if scope_value else ""
|
|
||||||
raise ValueError(f"Already subscribed to {sub_type}{scope_desc}")
|
|
||||||
|
|
||||||
# Insert subscription
|
|
||||||
now = time.time()
|
|
||||||
cursor = self._db.execute("""
|
|
||||||
INSERT INTO subscriptions (user_id, sub_type, schedule_time, schedule_day,
|
|
||||||
scope_type, scope_value, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""", (user_id, sub_type, schedule_time, schedule_day, scope_type, scope_value, now))
|
|
||||||
self._db.commit()
|
|
||||||
|
|
||||||
sub_id = cursor.lastrowid
|
|
||||||
return self._get_by_id(sub_id)
|
|
||||||
|
|
||||||
def _validate_time(self, time_str: str) -> bool:
|
|
||||||
"""Validate HHMM time format."""
|
|
||||||
if not time_str or len(time_str) != 4 or not time_str.isdigit():
|
|
||||||
return False
|
|
||||||
hours = int(time_str[:2])
|
|
||||||
minutes = int(time_str[2:])
|
|
||||||
return 0 <= hours <= 23 and 0 <= minutes <= 59
|
|
||||||
|
|
||||||
def _get_by_id(self, sub_id: int) -> dict:
|
|
||||||
"""Get subscription by ID."""
|
|
||||||
row = self._db.execute(
|
|
||||||
"SELECT * FROM subscriptions WHERE id = ?", (sub_id,)
|
|
||||||
).fetchone()
|
|
||||||
return self._row_to_dict(row) if row else None
|
|
||||||
|
|
||||||
def remove(self, user_id: str, sub_type: str = None) -> int:
|
|
||||||
"""Remove subscription(s).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: Subscriber node_num
|
|
||||||
sub_type: "daily", "weekly", "alerts", or None for all
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of subscriptions removed
|
|
||||||
"""
|
|
||||||
if sub_type and sub_type != "all":
|
|
||||||
cursor = self._db.execute(
|
|
||||||
"DELETE FROM subscriptions WHERE user_id = ? AND sub_type = ?",
|
|
||||||
(user_id, sub_type)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cursor = self._db.execute(
|
|
||||||
"DELETE FROM subscriptions WHERE user_id = ?",
|
|
||||||
(user_id,)
|
|
||||||
)
|
|
||||||
self._db.commit()
|
|
||||||
return cursor.rowcount
|
|
||||||
|
|
||||||
def get_user_subs(self, user_id: str) -> list[dict]:
|
|
||||||
"""Get all subscriptions for a user."""
|
|
||||||
rows = self._db.execute(
|
|
||||||
"SELECT * FROM subscriptions WHERE user_id = ? AND enabled = 1 ORDER BY created_at",
|
|
||||||
(user_id,)
|
|
||||||
).fetchall()
|
|
||||||
return [self._row_to_dict(r) for r in rows]
|
|
||||||
|
|
||||||
def get_due_subscriptions(self, current_time_hhmm: str, current_day: str) -> list[dict]:
|
|
||||||
"""Get subscriptions that should fire right now.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current_time_hhmm: Current time as "HHMM" (e.g., "1830")
|
|
||||||
current_day: Current day as 3-letter lowercase (e.g., "sun")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of subscription dicts that are due
|
|
||||||
"""
|
|
||||||
now = time.time()
|
|
||||||
due = []
|
|
||||||
|
|
||||||
# Get all daily/weekly subscriptions
|
|
||||||
rows = self._db.execute("""
|
|
||||||
SELECT * FROM subscriptions
|
|
||||||
WHERE sub_type IN ('daily', 'weekly') AND enabled = 1
|
|
||||||
""").fetchall()
|
|
||||||
|
|
||||||
current_minutes = int(current_time_hhmm[:2]) * 60 + int(current_time_hhmm[2:])
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
sub = self._row_to_dict(row)
|
|
||||||
schedule_time = sub.get("schedule_time")
|
|
||||||
if not schedule_time:
|
|
||||||
continue
|
|
||||||
|
|
||||||
schedule_minutes = int(schedule_time[:2]) * 60 + int(schedule_time[2:])
|
|
||||||
|
|
||||||
# 5-minute matching window
|
|
||||||
if abs(schedule_minutes - current_minutes) > 5:
|
|
||||||
continue
|
|
||||||
|
|
||||||
sub_type = sub["sub_type"]
|
|
||||||
last_sent = sub.get("last_sent", 0) or 0
|
|
||||||
|
|
||||||
if sub_type == "daily":
|
|
||||||
# Don't fire if sent within last 23 hours
|
|
||||||
if now - last_sent < 23 * 3600:
|
|
||||||
continue
|
|
||||||
due.append(sub)
|
|
||||||
|
|
||||||
elif sub_type == "weekly":
|
|
||||||
# Check day matches
|
|
||||||
schedule_day = sub.get("schedule_day", "").lower()
|
|
||||||
if schedule_day != current_day.lower():
|
|
||||||
continue
|
|
||||||
# Don't fire if sent within last 6 days
|
|
||||||
if now - last_sent < 6 * 24 * 3600:
|
|
||||||
continue
|
|
||||||
due.append(sub)
|
|
||||||
|
|
||||||
return due
|
|
||||||
|
|
||||||
def get_alert_subscribers(self, scope_type: str = None, scope_value: str = None) -> list[dict]:
|
|
||||||
"""Get users subscribed to alerts matching a scope.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
scope_type: "mesh", "region", or "node"
|
|
||||||
scope_value: Region name or node identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of subscription dicts where scope matches
|
|
||||||
"""
|
|
||||||
# Get all alert subscriptions
|
|
||||||
rows = self._db.execute("""
|
|
||||||
SELECT * FROM subscriptions
|
|
||||||
WHERE sub_type = 'alerts' AND enabled = 1
|
|
||||||
""").fetchall()
|
|
||||||
|
|
||||||
matching = []
|
|
||||||
for row in rows:
|
|
||||||
sub = self._row_to_dict(row)
|
|
||||||
sub_scope = sub.get("scope_type", "mesh")
|
|
||||||
sub_value = sub.get("scope_value")
|
|
||||||
|
|
||||||
# Mesh scope gets ALL alerts
|
|
||||||
if sub_scope == "mesh":
|
|
||||||
matching.append(sub)
|
|
||||||
# Region scope gets alerts for that region
|
|
||||||
elif sub_scope == "region" and scope_type == "region":
|
|
||||||
if sub_value and scope_value and sub_value.lower() == scope_value.lower():
|
|
||||||
matching.append(sub)
|
|
||||||
# Node scope gets alerts for that node
|
|
||||||
elif sub_scope == "node" and scope_type == "node":
|
|
||||||
if sub_value and scope_value and sub_value.lower() == scope_value.lower():
|
|
||||||
matching.append(sub)
|
|
||||||
|
|
||||||
return matching
|
|
||||||
|
|
||||||
def mark_sent(self, subscription_id: int):
|
|
||||||
"""Update last_sent timestamp to now."""
|
|
||||||
self._db.execute(
|
|
||||||
"UPDATE subscriptions SET last_sent = ? WHERE id = ?",
|
|
||||||
(time.time(), subscription_id)
|
|
||||||
)
|
|
||||||
self._db.commit()
|
|
||||||
|
|
||||||
def get_all_subs(self) -> list[dict]:
|
|
||||||
"""Get all subscriptions (for admin view)."""
|
|
||||||
rows = self._db.execute(
|
|
||||||
"SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY user_id, created_at"
|
|
||||||
).fetchall()
|
|
||||||
return [self._row_to_dict(r) for r in rows]
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
"""Close database connection."""
|
|
||||||
if self._db:
|
|
||||||
self._db.close()
|
|
||||||
self._db = None
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue