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:
Matt Johnson 2026-07-03 20:48:05 +00:00
commit 9e6a3715ed
19 changed files with 312 additions and 1537 deletions

View file

@ -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 />} />

View file

@ -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 },
], ],
}, },

View file

@ -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> {

View 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>
)
}

View file

@ -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>
)
}

View file

@ -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)' },

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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."""

View file

@ -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:

View file

@ -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 = []

View file

@ -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"

View 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;

View file

@ -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