feat(satpass): GUI safety controls panel — armed-state banner, dry-run toggle, rate cap

Tracking panel gains both new adapter-config keys:
- dry_run toggle (sky-blue accent, visually distinct from enabled)
- max_broadcasts_per_hour number input (1–60 range)

Armed-state banner at top of panel:
- OFF (grey) when disabled
- DRY RUN (sky-blue) when enabled + dry_run
- ⚠ LIVE (amber, pulsing) when enabled + !dry_run

Load/save through adapter-config API per 199929f pattern.
NORAD IDs helper updated to reflect opt-in semantics.

Bundle hash: index-DPN58SF4.js → index-Di1mw816.js
             index-CB06j1ej.css → index-WwNJt5S-.css

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-06-12 23:26:08 +00:00
commit 17808c3d65
4 changed files with 90 additions and 43 deletions

View file

@ -78,6 +78,8 @@ interface SatpassConfig {
observers: string[] observers: string[]
min_elevation: number min_elevation: number
norad_ids: string[] norad_ids: string[]
max_broadcasts_per_hour: number
dry_run: boolean
} }
// SWPC adapter config shape // SWPC adapter config shape
@ -315,6 +317,8 @@ export default function Environment() {
observers: [], observers: [],
min_elevation: 30, min_elevation: 30,
norad_ids: [], norad_ids: [],
max_broadcasts_per_hour: 4,
dry_run: true,
}) })
const [satpassOriginal, setSatpassOriginal] = useState<string>("") const [satpassOriginal, setSatpassOriginal] = useState<string>("")
@ -459,6 +463,8 @@ export default function Environment() {
observers: satpassData.observers?.value ?? [], observers: satpassData.observers?.value ?? [],
min_elevation: satpassData.min_elevation?.value ?? 30, min_elevation: satpassData.min_elevation?.value ?? 30,
norad_ids: satpassData.norad_ids?.value ?? [], norad_ids: satpassData.norad_ids?.value ?? [],
max_broadcasts_per_hour: satpassData.max_broadcasts_per_hour?.value ?? 4,
dry_run: satpassData.dry_run?.value ?? true,
} }
setSatpassConfig(cfg) setSatpassConfig(cfg)
setSatpassOriginal(JSON.stringify(cfg)) setSatpassOriginal(JSON.stringify(cfg))
@ -659,6 +665,12 @@ const save = async () => {
if (JSON.stringify(satpassConfig.norad_ids) !== JSON.stringify(orig.norad_ids)) { if (JSON.stringify(satpassConfig.norad_ids) !== JSON.stringify(orig.norad_ids)) {
await saveAdapterConfig("satpass", "norad_ids", satpassConfig.norad_ids) await saveAdapterConfig("satpass", "norad_ids", satpassConfig.norad_ids)
} }
if (satpassConfig.max_broadcasts_per_hour !== orig.max_broadcasts_per_hour) {
await saveAdapterConfig("satpass", "max_broadcasts_per_hour", satpassConfig.max_broadcasts_per_hour)
}
if (satpassConfig.dry_run !== orig.dry_run) {
await saveAdapterConfig("satpass", "dry_run", satpassConfig.dry_run)
}
setSatpassOriginal(JSON.stringify(satpassConfig)) setSatpassOriginal(JSON.stringify(satpassConfig))
} }
@ -1088,8 +1100,43 @@ const save = async () => {
))} ))}
</div> </div>
</>) </>)
case 'satpass': return ( case 'satpass': {
const armedState = !satpassConfig.enabled
? { label: 'OFF', color: 'text-[#777] bg-[#1a1a1a]', desc: '' }
: satpassConfig.dry_run
? { label: 'DRY RUN', color: 'text-sky-400 bg-sky-400/10 border border-sky-400/30', desc: ' — logging only, nothing transmits' }
: { label: '⚠ LIVE', color: 'text-amber-400 bg-amber-500/20 border-2 border-amber-500 font-bold animate-pulse', desc: ' — transmitting to mesh' }
return (
<div className="space-y-6"> <div className="space-y-6">
{/* Armed-state banner */}
<div className={`px-4 py-2.5 text-sm rounded ${armedState.color}`}>
<span className="font-semibold">{armedState.label}</span>
{armedState.desc && <span className="font-normal">{armedState.desc}</span>}
</div>
{/* Safety controls */}
<div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
Safety Controls
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-[#e0e0e0]">Dry run log instead of transmit</span>
<p className="text-xs text-[#666]">When enabled, passes are logged but never broadcast to mesh</p>
</div>
<button
onClick={() => setSatpassConfig({ ...satpassConfig, dry_run: !satpassConfig.dry_run })}
className={`relative w-10 h-5 rounded-full transition-colors ${satpassConfig.dry_run ? 'bg-sky-500' : 'bg-[#333]'}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${satpassConfig.dry_run ? 'translate-x-5' : ''}`} />
</button>
</div>
<NumberInput label="Max broadcasts / hour" value={satpassConfig.max_broadcasts_per_hour}
onChange={(v) => setSatpassConfig({ ...satpassConfig, max_broadcasts_per_hour: v })}
min={1} max={60} helper="Rate cap — broadcasts exceeding this limit are dropped" />
</div>
</div>
{/* Pass filters */}
<div> <div>
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3"> <div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">
Pass Filters Pass Filters
@ -1105,9 +1152,9 @@ const save = async () => {
helper="Observer names to include (empty = all)" /> helper="Observer names to include (empty = all)" />
<ListInput label="NORAD IDs" value={satpassConfig.norad_ids} <ListInput label="NORAD IDs" value={satpassConfig.norad_ids}
onChange={(v) => setSatpassConfig({ ...satpassConfig, norad_ids: v })} onChange={(v) => setSatpassConfig({ ...satpassConfig, norad_ids: v })}
helper="NORAD catalog IDs to track (empty = all)" /> helper="NORAD catalog IDs to broadcast (empty = broadcast nothing, opt-in only)" />
</div> </div>
) )}
} }
} }

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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-DPN58SF4.js"></script> <script type="module" crossorigin src="/assets/index-Di1mw816.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CB06j1ej.css"> <link rel="stylesheet" crossorigin href="/assets/index-WwNJt5S-.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>