feat(v0.6-phase2): rip out quiet hours entirely -- dashboard toggle, config schema, pipeline checks. Per Matt's repeated feedback (saved as feedback-quiet-hours-trash.md): silent is better than ugly, mesh users who need a fire alert at 3 AM need it at 3 AM. No replacement.

Backend removals:
  meshai/config.py
    - NotificationRuleConfig.override_quiet field
    - NotificationToggle.quiet_hours_override field
    - NotificationsConfig.quiet_hours_enabled / quiet_hours_start /
      quiet_hours_end fields
    - _default_toggles() no longer sets quiet_hours_override=True
    - rule migration helper no longer copies override_quiet
  meshai/notifications/router.py
    - self._quiet_enabled / _quiet_start / _quiet_end instance vars
    - _in_quiet_hours() method (deleted entirely)
    - The dispatch-time check that suppressed non-overriding rules
      during quiet hours
    - 'override_quiet': False dropped from subscription rule dicts
  meshai/notifications/pipeline/dispatcher.py
    - _toggle_to_rule() no longer passes override_quiet=... to the
      NotificationRuleConfig constructor

Test changes:
  tests/test_notification_toggles.py
    - RecChannel.deliver() no longer records override_quiet
    - test_quiet_hours_override_immediate_only deleted (only tested the
      removed feature)

Frontend removals (dashboard-frontend/src/pages/Notifications.tsx):
  - The 'Enable Quiet Hours' card with its time-range inputs deleted
  - 'Override Quiet Hours' per-rule toggle deleted
  - 'Quiet-hours override (immediate only)' per-toggle field deleted
  - quiet_hours_* fields removed from TS interfaces
  - quietHoursEnabled prop + state plumbing removed from the RuleEditor
  - All override_quiet: false defaults dropped from rule scaffolds
  - Unused Moon icon import dropped

Verification (post-strip):
  grep -rn 'quiet_hours\|override_quiet' meshai/*.py meshai/**/*.py
    -> 0 hits
  grep -rn 'quiet_hours\|override_quiet\|quietHours' dashboard-frontend/src
    -> 0 hits

Test count: 830 -> 829 (-1: test_quiet_hours_override_immediate_only
deleted; no other regressions).

No replacement. Mesh users who need a fire alert at 3 AM need it at 3 AM.
This commit is contained in:
Matt Johnson (via Claude) 2026-06-05 20:39:36 +00:00
commit b948ed775f
8 changed files with 88 additions and 205 deletions

View file

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import {
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
Calendar, AlertTriangle, Copy, Moon, AlertCircle, Layers,
Calendar, AlertTriangle, Copy, AlertCircle, Layers,
Wifi, WifiOff, Mail, Globe, Radio, MessageSquare,
Activity, Cloud, Flame, Car, Snowflake, Mountain, MapPin
} from 'lucide-react'
@ -35,7 +35,6 @@ interface NotificationRuleConfig {
webhook_url: string
webhook_headers: Record<string, string>
cooldown_minutes: number
override_quiet: boolean
region_scope: string[]
}
@ -45,7 +44,6 @@ interface NotificationToggle {
min_severity: string
regions: string[]
severity_channels: Record<string, string[]>
quiet_hours_override: boolean
broadcast_channel: number | null
node_ids: string[]
smtp_host: string
@ -61,9 +59,6 @@ interface NotificationToggle {
interface NotificationsConfig {
enabled: boolean
quiet_hours_enabled: boolean
quiet_hours_start: string
quiet_hours_end: string
cold_start_grace_seconds?: number
band_conditions_enabled?: boolean
band_conditions_schedule?: string[]
@ -150,7 +145,6 @@ const RULE_TEMPLATES = [
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 30,
override_quiet: false,
schedule_frequency: "daily" as const,
schedule_time: "07:00",
schedule_time_2: "",
@ -182,7 +176,6 @@ const RULE_TEMPLATES = [
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 15,
override_quiet: false,
schedule_frequency: "daily" as const,
schedule_time: "07:00",
schedule_time_2: "",
@ -214,7 +207,6 @@ const RULE_TEMPLATES = [
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 60,
override_quiet: false,
schedule_frequency: "daily" as const,
schedule_time: "07:00",
schedule_time_2: "",
@ -246,7 +238,6 @@ const RULE_TEMPLATES = [
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 30,
override_quiet: false,
schedule_frequency: "daily" as const,
schedule_time: "07:00",
schedule_time_2: "",
@ -278,7 +269,6 @@ const RULE_TEMPLATES = [
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 5,
override_quiet: true,
schedule_frequency: "daily" as const,
schedule_time: "07:00",
schedule_time_2: "",
@ -316,7 +306,6 @@ const RULE_TEMPLATES = [
delivery_type: "mesh_broadcast",
broadcast_channel: 0,
cooldown_minutes: 0,
override_quiet: false,
node_ids: [] as string[],
smtp_host: "",
smtp_port: 587,
@ -730,7 +719,6 @@ function NotificationRuleCard({
ruleIndex,
categories,
regions,
quietHoursEnabled,
onChange,
onDelete,
onDuplicate,
@ -740,7 +728,6 @@ function NotificationRuleCard({
ruleIndex: number
categories: AlertCategory[]
regions: RegionInfo[]
quietHoursEnabled: boolean
onChange: (r: NotificationRuleConfig) => void
onDelete: () => void
onDuplicate: () => void
@ -1400,18 +1387,7 @@ function NotificationRuleCard({
min={0}
helper="Min time between repeat sends"
info="Prevents alert spam. Same condition won't re-trigger this rule within this window."
/>
{quietHoursEnabled && (
<div className="flex items-end pb-1">
<Toggle
label="Override Quiet Hours"
checked={rule.override_quiet ?? false}
onChange={(v) => onChange({ ...rule, override_quiet: v })}
helper="Deliver during quiet hours"
/>
</div>
)}
</div>
/> </div>
{/* Rule statistics */}
{ruleStats && (
@ -1631,9 +1607,7 @@ function MasterToggles({ toggles, onChange }: {
))}
</tbody>
</table>
<ListInput label="Regions (empty = all)" value={t.regions || []} onChange={(v) => upd(key, { regions: v })} placeholder="Add region..." />
<Toggle label="Quiet-hours override (immediate only)" checked={!!t.quiet_hours_override} onChange={(v) => upd(key, { quiet_hours_override: v })} />
<div className="text-xs text-slate-500 pt-1">Channel config</div>
<ListInput label="Regions (empty = all)" value={t.regions || []} onChange={(v) => upd(key, { regions: v })} placeholder="Add region..." /> <div className="text-xs text-slate-500 pt-1">Channel config</div>
<NumberInput label="Broadcast channel" value={t.broadcast_channel ?? 0} onChange={(v) => upd(key, { broadcast_channel: v })} />
<ListInput label="DM node IDs" value={t.node_ids || []} onChange={(v) => upd(key, { node_ids: v })} placeholder="!nodeid" />
<ListInput label="Email recipients" value={t.recipients || []} onChange={(v) => upd(key, { recipients: v })} placeholder="ops@example.com" />
@ -1764,7 +1738,6 @@ export default function Notifications() {
webhook_url: '',
webhook_headers: {},
cooldown_minutes: 10,
override_quiet: false,
region_scope: [],
})
@ -2082,46 +2055,7 @@ export default function Notifications() {
/>
{config.enabled && (
<>
{/* Quiet Hours Section */}
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
<div className="flex items-center gap-2">
<Moon size={14} className="text-slate-400" />
<label className="text-xs text-slate-500 uppercase tracking-wide">Quiet Hours</label>
</div>
<Toggle
label="Enable Quiet Hours"
checked={config.quiet_hours_enabled ?? true}
onChange={(v) => setConfig({ ...config, quiet_hours_enabled: v })}
helper="Suppress non-emergency alerts during sleeping hours"
info="When enabled, ROUTINE alerts are suppressed during quiet hours. PRIORITY and IMMEDIATE always deliver."
/>
{config.quiet_hours_enabled && (
<>
<div className="grid grid-cols-2 gap-4">
<TimeInput
label="Start Time"
value={config.quiet_hours_start || '22:00'}
onChange={(v) => setConfig({ ...config, quiet_hours_start: v })}
helper="When quiet hours begin"
/>
<TimeInput
label="End Time"
value={config.quiet_hours_end || '06:00'}
onChange={(v) => setConfig({ ...config, quiet_hours_end: v })}
helper="When quiet hours end"
/>
</div>
<p className="text-xs text-slate-600">
Emergency alerts and rules with "Override Quiet Hours" enabled always deliver.
</p>
</>
)}
</div>
{/* Cold-start grace -- v0.5.8b */}
<> {/* Cold-start grace -- v0.5.8b */}
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Cold-start grace</label>
@ -2212,9 +2146,7 @@ export default function Notifications() {
rule={rule}
ruleIndex={i}
categories={categories}
regions={regions}
quietHoursEnabled={config.quiet_hours_enabled ?? true}
onChange={(r) => {
regions={regions} onChange={(r) => {
const newRules = [...(config.rules || [])]
newRules[i] = r
setConfig({ ...config, rules: newRules })

View file

@ -524,7 +524,6 @@ class NotificationRuleConfig:
# Behavior
cooldown_minutes: int = 10
override_quiet: bool = False
# Legacy field for migration (ignored in new format)
channel_ids: list = field(default_factory=list)
@ -542,7 +541,6 @@ class NotificationToggle:
regions: list = field(default_factory=list) # [] = all regions
# severity -> list of channel types (digest|mesh_broadcast|mesh_dm|email|webhook)
severity_channels: dict = field(default_factory=dict)
quiet_hours_override: bool = True # immediate-only quiet-hours bypass
# v0.5.2: staleness drop + per-toggle cooldown (Matt's spam fix)
freshness_seconds: int = 600 # drop events older than this at dispatcher entrance
cooldown_seconds: int = 300 # per (toggle, category, region) throttle window
@ -578,7 +576,6 @@ def _default_toggles() -> dict:
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast", "mesh_dm"],
},
quiet_hours_override=True,
)
for fam in TOGGLE_FAMILIES
}
@ -604,9 +601,6 @@ class NotificationsConfig:
"""Notification system settings."""
enabled: bool = False
quiet_hours_enabled: bool = True # Master toggle for quiet hours
quiet_hours_start: str = "22:00"
quiet_hours_end: str = "06:00"
# v0.5.8b cold-start grace: after the first event the dispatcher sees,
# suppress mesh broadcasts for N seconds to absorb any JetStream
# backlog. Persistence rows still get written -- only broadcasts are
@ -725,7 +719,6 @@ def _migrate_legacy_channels(notifications, data: dict):
webhook_url=ch.get("url", ""),
webhook_headers=ch.get("headers", {}),
cooldown_minutes=10,
override_quiet=old_rule.get("override_quiet", False),
)
migrated_rules.append(new_rule)

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-Dc1UcqB9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bj-HMHAO.css">
<script type="module" crossorigin src="/assets/index-C_RINFTf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-j88L17ja.css">
</head>
<body>
<div id="root"></div>

View file

@ -614,7 +614,6 @@ class Dispatcher:
recipients=list(getattr(tog, "recipients", []) or []),
webhook_url=getattr(tog, "webhook_url", ""),
webhook_headers=dict(getattr(tog, "webhook_headers", {}) or {}),
override_quiet=bool(getattr(tog, "quiet_hours_override", False) and event.severity == "immediate"),
)
def _matching_rules(self, event: Event) -> list:

View file

@ -35,9 +35,6 @@ class NotificationRouter:
timezone: str = "America/Boise",
):
self._rules: list[dict] = []
self._quiet_enabled = getattr(config, "quiet_hours_enabled", True)
self._quiet_start = getattr(config, "quiet_hours_start", "22:00")
self._quiet_end = getattr(config, "quiet_hours_end", "06:00")
self._timezone = timezone
self._recent: dict[tuple, float] = {} # (rule_name, category, event_key) -> last_sent_time
self._summarizer = MessageSummarizer(llm_backend) if llm_backend else None
@ -165,11 +162,6 @@ class NotificationRouter:
if not self._severity_meets(severity, min_severity):
continue
if self._quiet_enabled and self._in_quiet_hours():
if severity == "routine":
if not rule.get("override_quiet", False):
continue
cooldown = rule.get("cooldown_minutes", 10) * 60
event_id = alert.get("event_id", alert.get("message", "")[:50])
dedup_key = (rule_name, category, event_id)
@ -231,24 +223,6 @@ class NotificationRouter:
except ValueError:
return True
def _in_quiet_hours(self) -> bool:
"""Check if current time is within quiet hours."""
if not self._quiet_enabled:
return False
try:
from zoneinfo import ZoneInfo
tz = ZoneInfo(self._timezone)
now = datetime.now(tz)
current_time = now.strftime("%H:%M")
start = self._quiet_start
end = self._quiet_end
if start <= end:
return start <= current_time <= end
else:
return current_time >= start or current_time <= end
except Exception:
return False
def get_rules(self) -> list[dict]:
"""Get list of configured rules with stats."""
rules_with_stats = []
@ -780,7 +754,6 @@ class NotificationRouter:
"delivery_type": "mesh_dm",
"node_ids": [node_id],
"cooldown_minutes": 10,
"override_quiet": False,
})
return rule_name

View file

@ -17,7 +17,6 @@ class RecChannel:
"name": rule.name,
"broadcast_channel": rule.broadcast_channel,
"node_ids": list(rule.node_ids),
"override_quiet": rule.override_quiet,
})
return True
@ -115,14 +114,6 @@ def test_digest_channel_skipped_in_live_dispatch():
assert [r["delivery_type"] for r in rec] == ["mesh_broadcast"] # digest not live-dispatched
def test_quiet_hours_override_immediate_only():
cfg = _cfg(min_severity="routine",
severity_channels={"priority": ["mesh_broadcast"], "immediate": ["mesh_broadcast"]})
cfg.notifications.toggles["weather"].quiet_hours_override = True
assert _dispatch(cfg, _ev(severity="priority"))[0]["override_quiet"] is False
assert _dispatch(cfg, _ev(severity="immediate"))[0]["override_quiet"] is True
def test_category_maps_to_correct_family():
# seismic family toggle handles earthquake_event via get_toggle fallback
cfg = Config(); cfg.notifications.rules = []